Skip to content

feat: auto-inject workspace skills during provisioning#375

Closed
ptone wants to merge 6 commits into
mainfrom
contrib-refactor/auto-inject-skills
Closed

feat: auto-inject workspace skills during provisioning#375
ptone wants to merge 6 commits into
mainfrom
contrib-refactor/auto-inject-skills

Conversation

@ptone

@ptone ptone commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

Implements Sub-Plan C of the contrib refactor — modifies scion's provisioning code to auto-inject all skills from /workspace/skills/ into every agent during provisioning, replacing the appendExtraInstructions() text-composition mechanism.

  • C1/C3: Adds injectWorkspaceSkills() with conditional injection via SKILL.md frontmatter (inject_when: git_workspace | hub_enabled). For harnesses with native skill support, copies skill directories. For harnesses without, composites SKILL.md content into agent instructions.
  • C2: Removes appendExtraInstructions() and its call sites. Clears status boilerplate from resources/templates/default/agents.md (now delivered by agent-status-signals skill).
  • C4: 19 unit tests covering frontmatter parsing, conditional evaluation, directory copy, template precedence, fallback composition, and edge cases.

Key design decisions:

  • Template skills take precedence over workspace skills with the same name
  • Workspace root is derived from projectDir (strips .scion suffix)
  • Unknown inject_when values cause the skill to be skipped (fail-safe)

Test plan

  • All new unit tests pass (19 tests)
  • All existing provision tests pass (regression)
  • Full ./pkg/agent/ test suite passes

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces auto-injection of workspace skills from the /workspace/skills/ directory into the agent, supporting conditional injection based on the environment (e.g., git workspace or hub enabled) via YAML frontmatter in SKILL.md files. It also removes the deprecated appendExtraInstructions logic and the default agents.md template. The reviewer identified critical issues in parseSkillFrontmatter, including a potential CRLF bypass on Windows, prompt pollution from appending raw frontmatter in fallback mode, and silent YAML parsing failures. Suggestions were provided to normalize line endings, strip frontmatter before appending, use directory-derived skill names for better logging, and update the corresponding unit tests.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pkg/agent/provision.go
Comment on lines +1194 to +1208
// parseSkillFrontmatter extracts YAML frontmatter from a SKILL.md file.
// Returns zero-value skillFrontmatter if no frontmatter is found.
func parseSkillFrontmatter(data []byte) skillFrontmatter {
content := string(data)
if !strings.HasPrefix(content, "---\n") {
return skillFrontmatter{}
}
end := strings.Index(content[4:], "\n---")
if end < 0 {
return skillFrontmatter{}
}
var fm skillFrontmatter
_ = yaml.Unmarshal([]byte(content[4:4+end]), &fm)
return fm
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

CRLF Bypass & Frontmatter Pollution in Fallback Mode

There are three issues with the current implementation of parseSkillFrontmatter:

  1. CRLF Bypass (High Severity): If SKILL.md uses CRLF line endings (common on Windows or via git's autocrlf), strings.HasPrefix(content, "---\n") will fail because the file starts with ---\r\n. This causes parsing to fail silently, returning an empty fm.InjectWhen, which then defaults to unconditionally injecting the skill. This bypasses the conditional injection guard.
  2. Instruction Pollution (Medium Severity): In fallback mode (when skillsDir == ""), the entire SKILL.md file—including the raw YAML frontmatter—is appended to the agent instructions. This pollutes the prompt with system-level metadata that can confuse the LLM.
  3. Silent YAML Failures (Medium Severity): If there is a syntax error in the frontmatter, yaml.Unmarshal fails silently, defaulting to unconditional injection. We should log this error to aid debugging.

We can resolve all three issues by normalizing CRLF to LF, parsing the frontmatter, and returning the remaining markdown body with the frontmatter stripped.

func parseSkillFrontmatter(data []byte) (skillFrontmatter, string) {
	content := string(data)
	normalized := strings.ReplaceAll(content, "\r\n", "\n")
	if !strings.HasPrefix(normalized, "---\n") {
		return skillFrontmatter{}, content
	}
	end := strings.Index(normalized[4:], "\n---")
	if end < 0 {
		return skillFrontmatter{}, content
	}
	var fm skillFrontmatter
	if err := yaml.Unmarshal([]byte(normalized[4:4+end]), &fm); err != nil {
		util.Debugf("provision: failed to parse frontmatter in SKILL.md: %v", err)
	}
	bodyOffset := 4 + end + 4
	if bodyOffset < len(normalized) && normalized[bodyOffset] == '\n' {
		bodyOffset++
	}
	return fm, normalized[bodyOffset:]
}

Comment thread pkg/agent/provision.go
Comment on lines +1217 to +1231
// shouldInjectSkill checks whether a skill should be injected based on its
// inject_when frontmatter condition and the current provisioning context.
func shouldInjectSkill(fm skillFrontmatter, injCtx workspaceSkillsInjectionContext) bool {
switch fm.InjectWhen {
case "":
return true
case "git_workspace":
return injCtx.IsGit
case "hub_enabled":
return injCtx.HubEnabled
default:
util.Debugf("provision: unknown inject_when=%q for skill %q, skipping", fm.InjectWhen, fm.Name)
return false
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use Directory-Derived Skill Name in Debug Log

If the frontmatter does not explicitly define a name field, fm.Name will be empty, making the debug log on line 1228 unhelpful (e.g., unknown inject_when="..." for skill ""). Passing the directory-derived skillName to shouldInjectSkill ensures the log is always useful.

func shouldInjectSkill(skillName string, fm skillFrontmatter, injCtx workspaceSkillsInjectionContext) bool {
	switch fm.InjectWhen {
	case "":
		return true
	case "git_workspace":
		return injCtx.IsGit
	case "hub_enabled":
		return injCtx.HubEnabled
	default:
		util.Debugf("provision: unknown inject_when=%q for skill %q, skipping", fm.InjectWhen, skillName)
		return false
	}
}

Comment thread pkg/agent/provision.go
Comment on lines +1266 to 1313
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
skillName := entry.Name()
skillSrc := filepath.Join(wsSkillsDir, skillName)

// Parse SKILL.md frontmatter for injection conditions
skillMD := filepath.Join(skillSrc, "SKILL.md")
var fm skillFrontmatter
if data, err := os.ReadFile(skillMD); err == nil {
fm = parseSkillFrontmatter(data)
}

if !shouldInjectSkill(fm, injCtx) {
util.Debugf("provision: skipping workspace skill %q (inject_when=%q not satisfied)", skillName, fm.InjectWhen)
continue
}

if skillsDir != "" {
// Harness supports skills — copy the skill directory
skillDest := filepath.Join(agentHome, skillsDir, skillName)

// Template skills take precedence: skip if already present
if _, err := os.Stat(skillDest); err == nil {
util.Debugf("provision: workspace skill %q skipped (template skill takes precedence)", skillName)
continue
}

if err := os.MkdirAll(skillDest, 0755); err != nil {
return content, fmt.Errorf("failed to create workspace skill dir %s: %w", skillName, err)
}
if err := util.CopyDir(skillSrc, skillDest); err != nil {
return content, fmt.Errorf("failed to copy workspace skill %s: %w", skillName, err)
}
util.Debugf("provision: injected workspace skill %q into %s", skillName, skillDest)
} else {
// Harness lacks skill support — composite SKILL.md content into agent instructions
data, err := os.ReadFile(skillMD)
if err != nil {
util.Debugf("provision: workspace skill %q has no SKILL.md, skipping fallback injection", skillName)
continue
}
util.Debugf("provision: compositing workspace skill %q SKILL.md (%d bytes) into agent instructions", skillName, len(data))
content = append(content, '\n')
content = append(content, extra...)
content = append(content, data...)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update injectWorkspaceSkills to Use Stripped Body and New Signatures

Update the loop to use the new parseSkillFrontmatter and shouldInjectSkill signatures, and append only the stripped markdown body in fallback mode.

	for _, entry := range entries {
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
			continue
		}
		skillName := entry.Name()
		skillSrc := filepath.Join(wsSkillsDir, skillName)

		// Parse SKILL.md frontmatter for injection conditions
		skillMD := filepath.Join(skillSrc, "SKILL.md")
		var fm skillFrontmatter
		var skillBody string
		if data, err := os.ReadFile(skillMD); err == nil {
			fm, skillBody = parseSkillFrontmatter(data)
		}

		if !shouldInjectSkill(skillName, fm, injCtx) {
			util.Debugf("provision: skipping workspace skill %q (inject_when=%q not satisfied)", skillName, fm.InjectWhen)
			continue
		}

		if skillsDir != "" {
			// Harness supports skills — copy the skill directory
			skillDest := filepath.Join(agentHome, skillsDir, skillName)

			// Template skills take precedence: skip if already present
			if _, err := os.Stat(skillDest); err == nil {
				util.Debugf("provision: workspace skill %q skipped (template skill takes precedence)", skillName)
				continue
			}

			if err := os.MkdirAll(skillDest, 0755); err != nil {
				return content, fmt.Errorf("failed to create workspace skill dir %s: %w", skillName, err)
			}
			if err := util.CopyDir(skillSrc, skillDest); err != nil {
				return content, fmt.Errorf("failed to copy workspace skill %s: %w", skillName, err)
			}
			util.Debugf("provision: injected workspace skill %q into %s", skillName, skillDest)
		} else {
			// Harness lacks skill support — composite SKILL.md content into agent instructions
			if skillBody == "" {
				util.Debugf("provision: workspace skill %q has no SKILL.md content, skipping fallback injection", skillName)
				continue
			}
			util.Debugf("provision: compositing workspace skill %q SKILL.md (%d bytes) into agent instructions", skillName, len(skillBody))
			content = append(content, '\n')
			content = append(content, []byte(skillBody)...)
		}
	}

Comment on lines +2051 to +2092
func TestParseSkillFrontmatter(t *testing.T) {
t.Run("valid frontmatter", func(t *testing.T) {
data := []byte("---\nname: test-skill\ndescription: A test skill\ninject_when: git_workspace\n---\n\n# Content here\n")
fm := parseSkillFrontmatter(data)
if fm.Name != "test-skill" {
t.Errorf("Name=%q, want %q", fm.Name, "test-skill")
}
if fm.Description != "A test skill" {
t.Errorf("Description=%q, want %q", fm.Description, "A test skill")
}
if fm.InjectWhen != "git_workspace" {
t.Errorf("InjectWhen=%q, want %q", fm.InjectWhen, "git_workspace")
}
})

t.Run("no frontmatter", func(t *testing.T) {
data := []byte("# Just a markdown file\nNo frontmatter here\n")
fm := parseSkillFrontmatter(data)
if fm.Name != "" || fm.InjectWhen != "" {
t.Errorf("expected zero-value frontmatter, got %+v", fm)
}
})

t.Run("no inject_when field", func(t *testing.T) {
data := []byte("---\nname: unconditional\ndescription: Always inject\n---\n\n# Content\n")
fm := parseSkillFrontmatter(data)
if fm.Name != "unconditional" {
t.Errorf("Name=%q, want %q", fm.Name, "unconditional")
}
if fm.InjectWhen != "" {
t.Errorf("InjectWhen=%q, want empty", fm.InjectWhen)
}
})

t.Run("unclosed frontmatter", func(t *testing.T) {
data := []byte("---\nname: broken\n# No closing delimiter\n")
fm := parseSkillFrontmatter(data)
if fm.Name != "" {
t.Errorf("expected zero-value for unclosed frontmatter, got %+v", fm)
}
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update TestParseSkillFrontmatter for New Signature and CRLF Handling

Update the unit tests to match the new (skillFrontmatter, string) signature, assert that the stripped body is returned correctly, and add a test case for CRLF line endings.

func TestParseSkillFrontmatter(t *testing.T) {
	t.Run("valid frontmatter", func(t *testing.T) {
		data := []byte("---\nname: test-skill\ndescription: A test skill\ninject_when: git_workspace\n---\n\n# Content here\n")
		fm, body := parseSkillFrontmatter(data)
		if fm.Name != "test-skill" {
			t.Errorf("Name=%q, want %q", fm.Name, "test-skill")
		}
		if fm.Description != "A test skill" {
			t.Errorf("Description=%q, want %q", fm.Description, "A test skill")
		}
		if fm.InjectWhen != "git_workspace" {
			t.Errorf("InjectWhen=%q, want %q", fm.InjectWhen, "git_workspace")
		}
		if !strings.Contains(body, "# Content here") {
			t.Errorf("expected body to contain content, got %q", body)
		}
	})

	t.Run("no frontmatter", func(t *testing.T) {
		data := []byte("# Just a markdown file\nNo frontmatter here\n")
		fm, body := parseSkillFrontmatter(data)
		if fm.Name != "" || fm.InjectWhen != "" {
			t.Errorf("expected zero-value frontmatter, got %+v", fm)
		}
		if string(data) != body {
			t.Errorf("expected body to be original content, got %q", body)
		}
	})

	t.Run("no inject_when field", func(t *testing.T) {
		data := []byte("---\nname: unconditional\ndescription: Always inject\n---\n\n# Content\n")
		fm, body := parseSkillFrontmatter(data)
		if fm.Name != "unconditional" {
			t.Errorf("Name=%q, want %q", fm.Name, "unconditional")
		}
		if fm.InjectWhen != "" {
			t.Errorf("InjectWhen=%q, want empty", fm.InjectWhen)
		}
		if !strings.Contains(body, "# Content") {
			t.Errorf("expected body to contain content, got %q", body)
		}
	})

	t.Run("unclosed frontmatter", func(t *testing.T) {
		data := []byte("---\nname: broken\n# No closing delimiter\n")
		fm, body := parseSkillFrontmatter(data)
		if fm.Name != "" {
			t.Errorf("expected zero-value for unclosed frontmatter, got %+v", fm)
		}
		if string(data) != body {
			t.Errorf("expected body to be original content, got %q", body)
		}
	})

	t.Run("CRLF handling", func(t *testing.T) {
		data := []byte("---\r\nname: crlf-skill\r\ninject_when: git_workspace\r\n---\r\n\r\n# CRLF Content\r
")
		fm, body := parseSkillFrontmatter(data)
		if fm.Name != "crlf-skill" {
			t.Errorf("Name=%q, want %q", fm.Name, "crlf-skill")
		}
		if fm.InjectWhen != "git_workspace" {
			t.Errorf("InjectWhen=%q, want %q", fm.InjectWhen, "git_workspace")
		}
		if !strings.Contains(body, "# CRLF Content") {
			t.Errorf("expected body to contain content, got %q", body)
		}
	})
}

Comment on lines +2094 to +2118
func TestShouldInjectSkill(t *testing.T) {
tests := []struct {
name string
injectWhen string
ctx workspaceSkillsInjectionContext
want bool
}{
{"unconditional always injects", "", workspaceSkillsInjectionContext{}, true},
{"git_workspace with git", "git_workspace", workspaceSkillsInjectionContext{IsGit: true}, true},
{"git_workspace without git", "git_workspace", workspaceSkillsInjectionContext{IsGit: false}, false},
{"hub_enabled with hub", "hub_enabled", workspaceSkillsInjectionContext{HubEnabled: true}, true},
{"hub_enabled without hub", "hub_enabled", workspaceSkillsInjectionContext{HubEnabled: false}, false},
{"unknown condition skips", "unknown_condition", workspaceSkillsInjectionContext{IsGit: true, HubEnabled: true}, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fm := skillFrontmatter{Name: "test", InjectWhen: tt.injectWhen}
got := shouldInjectSkill(fm, tt.ctx)
if got != tt.want {
t.Errorf("shouldInjectSkill(inject_when=%q)=%v, want %v", tt.injectWhen, got, tt.want)
}
})
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update TestShouldInjectSkill for New Signature

Update the unit tests to pass the skill name to shouldInjectSkill.

func TestShouldInjectSkill(t *testing.T) {
	tests := []struct {
		name       string
		injectWhen string
		ctx        workspaceSkillsInjectionContext
		want       bool
	}{
		{"unconditional always injects", "", workspaceSkillsInjectionContext{}, true},
		{"git_workspace with git", "git_workspace", workspaceSkillsInjectionContext{IsGit: true}, true},
		{"git_workspace without git", "git_workspace", workspaceSkillsInjectionContext{IsGit: false}, false},
		{"hub_enabled with hub", "hub_enabled", workspaceSkillsInjectionContext{HubEnabled: true}, true},
		{"hub_enabled without hub", "hub_enabled", workspaceSkillsInjectionContext{HubEnabled: false}, false},
		{"unknown condition skips", "unknown_condition", workspaceSkillsInjectionContext{IsGit: true, HubEnabled: true}, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fm := skillFrontmatter{Name: "test", InjectWhen: tt.injectWhen}
			got := shouldInjectSkill("test", fm, tt.ctx)
			if got != tt.want {
				t.Errorf("shouldInjectSkill(inject_when=%q)=%v, want %v", tt.injectWhen, got, tt.want)
			}
		})
	}
}

ptone and others added 6 commits July 2, 2026 18:09
…uild context (GoogleCloudPlatform#576)

* Refocus image builds on base and harness catalog

* image-build: add gemini-cli harness build step

* image-build: copy resources/ and harnesses/ dirs into build context

---------

Co-authored-by: Scion Agent (hr-arch-codex-2) <agent@scion.dev>
Add injectWorkspaceSkills() to discover and inject all skills from the
workspace-level skills/ directory into every agent at provision time.

For harnesses with native skill support (SkillsDir != ""), skill
directories are copied into the agent's harness-specific skills
location. For harnesses without skill support, SKILL.md content is
composited into the agent instructions as a fallback.

Template skills take precedence — if a template already installed a
skill with the same name, the workspace skill is skipped.

Includes conditional injection via SKILL.md frontmatter: skills can
declare inject_when (git_workspace, hub_enabled) to control when they
are injected based on provisioning context.

Implements Sub-Plan C phases C1 and C3 of the contrib refactor.
Remove the appendExtraInstructions() function and its two call sites,
replacing them with the workspace skill auto-injection mechanism.

The status boilerplate in resources/templates/default/agents.md is
cleared — this content is now delivered by the agent-status-signals
workspace skill via auto-injection.

Existing tests for appendExtraInstructions are removed. Replacement
coverage is added in Phase C4.

Implements Sub-Plan C phase C2 of the contrib refactor.
Covers:
- parseSkillFrontmatter: valid frontmatter, missing frontmatter,
  unclosed frontmatter, omitted inject_when
- shouldInjectSkill: unconditional, git_workspace, hub_enabled,
  and unknown conditions
- injectWorkspaceSkills with harness skill support: unconditional
  injection, conditional git_workspace/hub_enabled, template
  precedence, graceful missing dir, hidden dir/file skipping
- injectWorkspaceSkills fallback composition: SKILL.md content
  appended to instructions, conditional respected, missing
  SKILL.md skipped

Implements Sub-Plan C phase C4 of the contrib refactor.
M1: Log YAML parse errors in parseSkillFrontmatter instead of
    silently discarding them.

M2: Add debug log for symlinked skill directories that are skipped
    by os.ReadDir's IsDir() check.

M3: Isolate test subtests with per-subtest t.TempDir() via a shared
    setupWorkspaceSkillsTest helper, eliminating state leaks between
    subtests.

Also adds test for malformed YAML frontmatter (test gap identified
in review).
Review feedback (Gemini code review):
- CRLF normalization: add strings.ReplaceAll for \r\n before parsing
  SKILL.md frontmatter
- Double file read: read SKILL.md once and reuse bytes for both
  frontmatter parsing and fallback content injection
- Empty agent_instructions: inject workspace skills fallback content
  even when no base agent_instructions are defined

CI fixes:
- golangci-lint errcheck: check return values of os.MkdirAll and
  os.WriteFile in all new test helpers and subtests
- TestInitMachine_RestoresDeletedFiles: switch test target from
  agents.md (now intentionally empty) to scion-agent.yaml
@ptone
ptone force-pushed the contrib-refactor/auto-inject-skills branch from 2bef22f to c3d6134 Compare July 2, 2026 16:15
@ptone

ptone commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

Work merged upstream via GoogleCloudPlatform/scion PR GoogleCloudPlatform#573. Closing fork staging PR.

@ptone ptone closed this Jul 10, 2026
@ptone
ptone deleted the contrib-refactor/auto-inject-skills branch July 12, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant