@workit-dev/core 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Skill routing at the moment it matters. The session-start contract names a
|
|
2
|
+
// trigger for each skill, but in a long or compacted session that table loses
|
|
3
|
+
// weight and agents act without loading the skill. Three deterministic aids,
|
|
4
|
+
// inside a Workit workspace only:
|
|
5
|
+
// - a prompt that asks for a skill's work in so many words gets one
|
|
6
|
+
// advisory line naming the skill;
|
|
7
|
+
// - a delivery command (push, PR create or merge, CI wait) gets one line
|
|
8
|
+
// naming workit-ship;
|
|
9
|
+
// - each skill load is recorded as a `skill.loaded` ledger row, so a nudge
|
|
10
|
+
// stops once the skill is loaded and workit-retro can count skill use.
|
|
11
|
+
// A session is nudged about one skill at most once (a marker per session
|
|
12
|
+
// in the workspace store), never in a subagent, never once it loaded the skill.
|
|
13
|
+
// Every failure answers "no nudge": hooks fail open.
|
|
14
|
+
import {
|
|
15
|
+
skillLoadWording,
|
|
16
|
+
WORKIT_METHOD_SKILLS,
|
|
17
|
+
WORKIT_SKILL_ALIASES,
|
|
18
|
+
type SkillHost,
|
|
19
|
+
type WorkitSkill,
|
|
20
|
+
} from "../core/skill-manifests";
|
|
21
|
+
import { appendHookObserved, readLedger } from "../ledger";
|
|
22
|
+
import type { HookDecision, HookInput, HostId } from "./protocol";
|
|
23
|
+
import { inWorkitWorkspace, rawDelivery, rawGitPre, workitInvocations } from "./raw-git";
|
|
24
|
+
import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
|
|
25
|
+
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
26
|
+
|
|
27
|
+
/** Skills the user starts; a nudge says to offer them, never to load them. */
|
|
28
|
+
const USER_INVOKED: ReadonlySet<WorkitSkill> = new Set(["workit-retro", "workit-architecture"]);
|
|
29
|
+
|
|
30
|
+
const SKILL_HOST: Record<HostId, SkillHost> = {
|
|
31
|
+
claude_code: "claude-code",
|
|
32
|
+
opencode: "opencode",
|
|
33
|
+
codex_cli: "codex",
|
|
34
|
+
codex_desktop: "codex",
|
|
35
|
+
cursor: "cursor",
|
|
36
|
+
pi: "pi",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prompt phrasings that ask for a skill's work. The contract's single-word
|
|
41
|
+
* triggers (plan, build, merge, CI) are for the model's judgment; a hook
|
|
42
|
+
* cannot tell "plan the cache" from "what's in the plan?", so it matches
|
|
43
|
+
* phrases only. Ordered by precedence: a broken thing is debug work before
|
|
44
|
+
* it is a feature or a plan.
|
|
45
|
+
*/
|
|
46
|
+
const PROMPT_INTENTS: ReadonlyArray<readonly [WorkitSkill, RegExp]> = [
|
|
47
|
+
[
|
|
48
|
+
"workit-debug",
|
|
49
|
+
/\b(flaky|regressed|(?:is|introduced) a regression|regression (?:in|after|since|from)|(?:is|are|got|looks) broken|debug (?:this|it|the|why)|fix (?:the|this|a) bug)\b/i,
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
"workit-continue",
|
|
53
|
+
/\b(pick up where|where (?:were|did) we|resume (?:the|this|my|our|work|where)|hand ?off)\b/i,
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"workit-review",
|
|
57
|
+
/\b(blast radius|code review|review (?:the|this|my|PR|pr|#\d+|branch|diff|changes))\b/i,
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"workit-ship",
|
|
61
|
+
/\b(ship (?:it|this|the)|babysit|open (?:a|the) (?:PR|pr|MR|mr)(?=\s*$|\s+(?:for|to|on|with|and|against|into|from|after|once)\b|[.!,;])|merge (?:the|this|my) (?:PR|pr|MR|mr|branch)|(?:get|until|make) CI (?:is )?green)\b/i,
|
|
62
|
+
],
|
|
63
|
+
["workit-bdd", /\b(BDD|TDD|acceptance criteria|Given\/When\/Then)(?![\w/])/],
|
|
64
|
+
["workit-test-audit", /\b(test audit|audit the tests|tautolog\w*|weak tests)\b/i],
|
|
65
|
+
["workit-deslop", /\b(deslop|slop|dead code)\b/i],
|
|
66
|
+
[
|
|
67
|
+
"workit-fanout",
|
|
68
|
+
/\b(fan(?: |-)?out|parallelize|parallel agents|agent swarm|swarm of agents)\b/i,
|
|
69
|
+
],
|
|
70
|
+
["workit-verify-app", /\b(verify the app|smoke test|prove it works)\b/i],
|
|
71
|
+
["workit-retro", /\b((?:a|the) retro(?![-\w]|\.\w)|retrospective)/i],
|
|
72
|
+
["workit-architecture", /\b(deepen (?:the )?modules|architecture (?:review|pass|audit))\b/i],
|
|
73
|
+
[
|
|
74
|
+
"workit-shape",
|
|
75
|
+
/\b(brainstorm\w*|grill me|should we|let'?s plan|plan (?:out|how|the|a|for)|(?:write|draft) (?:a|the) spec)\b/i,
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"workit-implement",
|
|
79
|
+
/\b(implement (?:the|this|a|it)|add (?:a|the) feature|build (?:a|an|the) (?:new )?(?:feature|command|verb|page|endpoint|component|cli|tool))\b/i,
|
|
80
|
+
],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** The workit skill a loaded name refers to (`workit:shape`, `workit-shape`, `wk-shape`). */
|
|
84
|
+
export const workitSkillOf = (name: string): WorkitSkill | null => {
|
|
85
|
+
const bare = name.trim().replace(/^\//, "").toLowerCase();
|
|
86
|
+
const alias = (WORKIT_SKILL_ALIASES as Record<string, WorkitSkill>)[bare];
|
|
87
|
+
if (alias) return alias;
|
|
88
|
+
const skill = bare.replace(/^workit:/, "workit-");
|
|
89
|
+
return (WORKIT_METHOD_SKILLS as readonly string[]).includes(skill)
|
|
90
|
+
? (skill as WorkitSkill)
|
|
91
|
+
: null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The skill a prompt asks for, by precedence, with the phrase that did. */
|
|
95
|
+
export const promptTrigger = (prompt: string): { skill: WorkitSkill; trigger: string } | null => {
|
|
96
|
+
// Code blocks and quoted output carry words the user did not ask with.
|
|
97
|
+
const text = prompt.replace(/```[\s\S]*?```/g, " ").slice(0, 4000);
|
|
98
|
+
for (const [skill, pattern] of PROMPT_INTENTS) {
|
|
99
|
+
const match = pattern.exec(text);
|
|
100
|
+
if (match) return { skill, trigger: match[1] ?? match[0] };
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Skills this session already loaded, from its `skill.loaded` rows. */
|
|
106
|
+
const loadedSkills = (cwd: string, session: string): Set<string> => {
|
|
107
|
+
const ledger = readLedger(cwd);
|
|
108
|
+
const loaded = new Set<string>();
|
|
109
|
+
if (!ledger.ok) return loaded;
|
|
110
|
+
for (const row of ledger.value.rows)
|
|
111
|
+
if (
|
|
112
|
+
row.type === "skill.loaded" &&
|
|
113
|
+
row.actor.session === session &&
|
|
114
|
+
typeof row.skill === "string"
|
|
115
|
+
)
|
|
116
|
+
loaded.add(row.skill);
|
|
117
|
+
return loaded;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const SKILL_FILE = /(?:^|[\\/])(workit-[a-z-]+)[\\/]SKILL\.md$/;
|
|
121
|
+
|
|
122
|
+
/** The workit skill whose SKILL.md a path names (Pi's read tool). */
|
|
123
|
+
export const skillFileIn = (file: string): string | null => SKILL_FILE.exec(file)?.[1] ?? null;
|
|
124
|
+
|
|
125
|
+
/** Commands that read a file for the agent: a shell-loaded skill is one of these on its SKILL.md. */
|
|
126
|
+
const READERS = new Set([
|
|
127
|
+
"cat",
|
|
128
|
+
"sed",
|
|
129
|
+
"head",
|
|
130
|
+
"tail",
|
|
131
|
+
"less",
|
|
132
|
+
"more",
|
|
133
|
+
"bat",
|
|
134
|
+
"nl",
|
|
135
|
+
"get-content",
|
|
136
|
+
"gc",
|
|
137
|
+
"type",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
/** The workit skill whose SKILL.md a shell command reads (Codex, Cursor); never git, grep or an editor. */
|
|
141
|
+
export const skillReadIn = (command: string, dialect: ShellDialect = "posix"): string | null => {
|
|
142
|
+
for (const { words } of segmentsOf(command, dialect)) {
|
|
143
|
+
if (!READERS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) continue;
|
|
144
|
+
// `sed -i` edits the file; it does not load it.
|
|
145
|
+
if (words.some((word) => /^(?:-i(?:\.\w+)?|--in-place(?:=.*)?)$/.test(word))) continue;
|
|
146
|
+
for (const word of words.slice(1)) {
|
|
147
|
+
const skill = skillFileIn(word);
|
|
148
|
+
if (skill) return skill;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Record that the session loaded a workit skill (`via`: the tool, a slash command, a file read). */
|
|
155
|
+
export function recordSkillLoad(
|
|
156
|
+
input: HookInput,
|
|
157
|
+
name: string,
|
|
158
|
+
via: "tool" | "slash" | "read",
|
|
159
|
+
): void {
|
|
160
|
+
try {
|
|
161
|
+
const skill = workitSkillOf(name);
|
|
162
|
+
const session = input.session.id;
|
|
163
|
+
if (!skill || !session) return;
|
|
164
|
+
if (!inWorkitWorkspace(input.cwd)) return;
|
|
165
|
+
const file = sessionMarkerFile(input, session);
|
|
166
|
+
if (!file) return;
|
|
167
|
+
const marker = readSessionMarker(file);
|
|
168
|
+
if (marker.loaded.includes(skill)) return;
|
|
169
|
+
writeSessionMarker(file, { ...marker, loaded: [...marker.loaded, skill] });
|
|
170
|
+
if (loadedSkills(input.cwd, session).has(skill)) return;
|
|
171
|
+
appendHookObserved(input.cwd, {
|
|
172
|
+
type: "skill.loaded",
|
|
173
|
+
actor: { host: input.host, session, agentId: input.session.agentId },
|
|
174
|
+
skill,
|
|
175
|
+
via,
|
|
176
|
+
});
|
|
177
|
+
} catch {
|
|
178
|
+
// Fail open: a lost row only means a later nudge or a retro miscount.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The nudge for `skill` once per session: null when it was nudged or loaded. */
|
|
183
|
+
const nudgeOnce = (
|
|
184
|
+
input: HookInput,
|
|
185
|
+
session: string,
|
|
186
|
+
skill: WorkitSkill,
|
|
187
|
+
line: string,
|
|
188
|
+
): string | null => {
|
|
189
|
+
const file = sessionMarkerFile(input, session);
|
|
190
|
+
if (!file) return null;
|
|
191
|
+
const marker = readSessionMarker(file);
|
|
192
|
+
if (marker.nudged.includes(skill) || marker.loaded.includes(skill)) return null;
|
|
193
|
+
if (loadedSkills(input.cwd, session).has(skill)) return null;
|
|
194
|
+
writeSessionMarker(file, { ...marker, nudged: [...marker.nudged, skill] });
|
|
195
|
+
return line;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const nudgeText = (input: HookInput, skill: WorkitSkill, why: string): string =>
|
|
199
|
+
USER_INVOKED.has(skill)
|
|
200
|
+
? `Workit: ${why} looks like ${skill} work, which the user starts; offer it (/${skill.replace(/^workit-/, "wk-")}) rather than loading it.`
|
|
201
|
+
: `Workit: ${why} looks like ${skill} work; if it is, ${skillLoadWording(SKILL_HOST[input.host], skill)} first.`;
|
|
202
|
+
|
|
203
|
+
/** A skill the prompt already loads: `/wk-shape`, `/workit:shape`, Pi's expanded
|
|
204
|
+
* `/skill:` block, or OpenCode's rewritten `/wk-*` command. */
|
|
205
|
+
const promptLoads = (prompt: string): string | null =>
|
|
206
|
+
/^\s*\/(?:skill:)?([\w:-]+)/.exec(prompt)?.[1] ??
|
|
207
|
+
/^\s*<skill name="([\w:-]+)"/.exec(prompt)?.[1] ??
|
|
208
|
+
/^\s*Load the (workit-[a-z-]+) skill with the skill tool\b/.exec(prompt)?.[1] ??
|
|
209
|
+
null;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A prompt that relays another agent's words, not the user's: Claude Code
|
|
213
|
+
* delivers subagent hand-backs, teammate messages and background-task
|
|
214
|
+
* notifications through UserPromptSubmit. A reviewer's report that says
|
|
215
|
+
* "regressed" is not a request for workit-debug.
|
|
216
|
+
*/
|
|
217
|
+
export const relayedMessage = (prompt: string): boolean =>
|
|
218
|
+
/^\s*(?:Another Claude session sent a message\b|<agent-message\s+from=|\[SYSTEM NOTIFICATION\b|<task-notification>)/.test(
|
|
219
|
+
prompt,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
/** A main session's prompt in a Workit workspace: one advisory line naming the skill it asks for. */
|
|
223
|
+
export function promptNudge(input: HookInput, prompt: string | null | undefined): string | null {
|
|
224
|
+
try {
|
|
225
|
+
const session = input.session.id;
|
|
226
|
+
if (!prompt || !session || input.session.agentId || relayedMessage(prompt)) return null;
|
|
227
|
+
const loads = promptLoads(prompt);
|
|
228
|
+
if (loads) {
|
|
229
|
+
// The prompt already loads its skill; record it, never nudge.
|
|
230
|
+
recordSkillLoad(input, loads, "slash");
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const hit = promptTrigger(prompt);
|
|
234
|
+
if (!hit || !inWorkitWorkspace(input.cwd)) return null;
|
|
235
|
+
return nudgeOnce(
|
|
236
|
+
input,
|
|
237
|
+
session,
|
|
238
|
+
hit.skill,
|
|
239
|
+
nudgeText(input, hit.skill, `this request ("${hit.trigger}")`),
|
|
240
|
+
);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const WORKIT_DELIVERY = /^(?:pr (?:create|merge)|ci wait|git push)\b/;
|
|
247
|
+
/** Does the command run a workit delivery verb (also inside `bash -c '…'`), never help or a dry run? */
|
|
248
|
+
const runsWorkitDelivery = (command: string, dialect: ShellDialect): boolean =>
|
|
249
|
+
workitInvocations(command, dialect).some(
|
|
250
|
+
(args) =>
|
|
251
|
+
!args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run") &&
|
|
252
|
+
WORKIT_DELIVERY.test(args.join(" ")),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Does the command deliver in a Workit workspace: a push, a PR/MR create or
|
|
257
|
+
* merge, or a CI wait (never help or a dry run)?
|
|
258
|
+
*/
|
|
259
|
+
export const isDeliveryCommand = (
|
|
260
|
+
cwd: string,
|
|
261
|
+
command: string,
|
|
262
|
+
dialect: ShellDialect = "posix",
|
|
263
|
+
): boolean =>
|
|
264
|
+
rawDelivery(cwd, command, dialect) ||
|
|
265
|
+
(runsWorkitDelivery(command, dialect) && inWorkitWorkspace(cwd));
|
|
266
|
+
|
|
267
|
+
/** A delivery command in a main session that has not loaded workit-ship. */
|
|
268
|
+
export function shipNudge(input: HookInput, command: string): string | null {
|
|
269
|
+
try {
|
|
270
|
+
const session = input.session.id;
|
|
271
|
+
if (!session || input.session.agentId) return null;
|
|
272
|
+
const dialect = input.event.kind === "shell.pre" ? (input.event.dialect ?? "posix") : "posix";
|
|
273
|
+
if (!isDeliveryCommand(input.cwd, command, dialect)) return null;
|
|
274
|
+
return nudgeOnce(
|
|
275
|
+
input,
|
|
276
|
+
session,
|
|
277
|
+
"workit-ship",
|
|
278
|
+
nudgeText(input, "workit-ship", "this delivery step"),
|
|
279
|
+
);
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `decision` with `line` added: a context grows by a line, `none` becomes one. */
|
|
286
|
+
export const withContextLine = (decision: HookDecision, line: string | null): HookDecision => {
|
|
287
|
+
if (!line) return decision;
|
|
288
|
+
if (decision.kind === "none") return { kind: "context", text: line };
|
|
289
|
+
if (decision.kind === "context") return { kind: "context", text: `${decision.text}\n${line}` };
|
|
290
|
+
return decision;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** Hosts whose shell nudge rides on the tool result: the raw-git nudge plus the ship line. */
|
|
294
|
+
export const shellNudge = (input: HookInput, command: string): HookDecision =>
|
|
295
|
+
withContextLine(rawGitPre(input, command), shipNudge(input, command));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The spawn gate (#432, #654), Claude Code's PreToolUse on the Agent tool.
|
|
2
|
+
// No spawn runs under host isolation: Claude Code's worktree isolation refuses
|
|
3
|
+
// every command that wraps git and cannot be turned off, so `isolation` is
|
|
4
|
+
// stripped from every agent type (an implementer works in its brief's
|
|
5
|
+
// WORKTREE); under auto mode, which refuses a rewritten spawn, the spawn is
|
|
6
|
+
// denied with the same args without it (agent-model.ts). A `workit:implementer` spawn also carries a Workit brief: a
|
|
7
|
+
// prompt with no WORKTREE line (no brief, or a brief rendered before the
|
|
8
|
+
// worktree existed) is denied with the commands that make one. A pointer
|
|
9
|
+
// prompt (its WORKER, TIER, TIMEBOX and WORKTREE lines plus "render
|
|
10
|
+
// `workit fanout brief <slice>`") passes: it spares the lead's context, and
|
|
11
|
+
// the launcher fast path (workit-hook.mjs) keeps this same WORKTREE test.
|
|
12
|
+
import { agentModelDecision, type SpawnChange } from "./agent-model";
|
|
13
|
+
import type { HostDescriptor } from "./descriptor";
|
|
14
|
+
import type { HookDecision } from "./protocol";
|
|
15
|
+
import { workitRole } from "./steering";
|
|
16
|
+
|
|
17
|
+
/** A brief's WORKTREE line naming a made worktree (`fanout brief` says "not made yet" otherwise). */
|
|
18
|
+
const MADE_WORKTREE = /^WORKTREE:[ \t]*(?!not made yet)\S(?![^\n]*not made yet)/mu;
|
|
19
|
+
|
|
20
|
+
const UNBLOCK = "workit fanout worktree create <slice>";
|
|
21
|
+
const BRIEF = "workit fanout brief <slice>";
|
|
22
|
+
|
|
23
|
+
/** The gate on a Claude Code spawn: a deny, or the input it lets run and what it changed. */
|
|
24
|
+
type Gate =
|
|
25
|
+
| { kind: "deny"; decision: HookDecision }
|
|
26
|
+
| { kind: "run"; input: Record<string, unknown>; change: SpawnChange };
|
|
27
|
+
|
|
28
|
+
const gate = (descriptor: HostDescriptor, tool: Record<string, unknown>): Gate => {
|
|
29
|
+
const type = typeof tool.subagent_type === "string" ? tool.subagent_type : "";
|
|
30
|
+
const implementer = workitRole(descriptor, type) === "implementer";
|
|
31
|
+
if (implementer && (typeof tool.prompt !== "string" || !MADE_WORKTREE.test(tool.prompt)))
|
|
32
|
+
return {
|
|
33
|
+
kind: "deny",
|
|
34
|
+
decision: {
|
|
35
|
+
kind: "deny",
|
|
36
|
+
reason: `workit spawn gate: a ${type} spawn needs a Workit brief with a WORKTREE line, and this prompt has none. Even for one slice: run \`${UNBLOCK}\`, then \`${BRIEF}\`, and spawn again with its text as the prompt and the Agent args its SPAWN line names (a slice no plan registers needs \`workit fanout plan <plan.json>\` first).`,
|
|
37
|
+
unblock: UNBLOCK,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
if (tool.isolation === undefined)
|
|
41
|
+
return { kind: "run", input: tool, change: { dropped: [], reasons: [], notes: [] } };
|
|
42
|
+
const { isolation: _dropped, ...input } = tool;
|
|
43
|
+
const why = `${
|
|
44
|
+
implementer
|
|
45
|
+
? "it works in the brief's WORKTREE"
|
|
46
|
+
: "it runs in your checkout; for its own worktree run `workit fanout worktree create <slice>` and name it in the prompt"
|
|
47
|
+
} (Claude Code's isolation refuses wrapped git).`;
|
|
48
|
+
return {
|
|
49
|
+
kind: "run",
|
|
50
|
+
input,
|
|
51
|
+
change: {
|
|
52
|
+
dropped: ["isolation"],
|
|
53
|
+
reasons: [`Isolation goes: ${why}`],
|
|
54
|
+
notes: [`Workit removed isolation from this ${type || "general-purpose"} spawn: ${why}`],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The gate, then the role's model (agent-model.ts) on what it lets run: one
|
|
61
|
+
* `updatedInput` for both, or under auto mode one deny naming the exact args.
|
|
62
|
+
*/
|
|
63
|
+
export const spawnDecision = (
|
|
64
|
+
descriptor: HostDescriptor,
|
|
65
|
+
cwd: string,
|
|
66
|
+
tool: Record<string, unknown>,
|
|
67
|
+
permissionMode: string | null = null,
|
|
68
|
+
): HookDecision => {
|
|
69
|
+
const checked = gate(descriptor, tool);
|
|
70
|
+
if (checked.kind === "deny") return checked.decision;
|
|
71
|
+
return agentModelDecision(cwd, checked.input, { change: checked.change, permissionMode });
|
|
72
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The standing-order revision signal (#320). Every standing add or clear
|
|
2
|
+
// writes the new row's id to `<store>/standing/rev`; each agent that a hook
|
|
3
|
+
// has answered since keeps the revision it last saw in
|
|
4
|
+
// `<store>/standing/seen/<key>.json`. A tool call whose agent already saw the
|
|
5
|
+
// current revision costs two small reads and never loads the Workit runtime.
|
|
6
|
+
// fs, path and crypto only: the Codex launcher imports this before the
|
|
7
|
+
// runtime, and the Claude Code launcher (bin/workit-hook.mjs) keeps a copy
|
|
8
|
+
// that test/workit-core/hooks/standing-signal.test.ts holds equal.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export type StandingSeen = { rev: string | null; ids: string[] };
|
|
14
|
+
|
|
15
|
+
/** `<git common dir>/workit` by fs reads alone, or null outside a repository. */
|
|
16
|
+
export const gitStoreDir = (cwd: string): string | null => {
|
|
17
|
+
try {
|
|
18
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
19
|
+
const dotGit = path.join(dir, ".git");
|
|
20
|
+
if (fs.existsSync(dotGit)) {
|
|
21
|
+
let gitDir = dotGit;
|
|
22
|
+
if (fs.statSync(dotGit).isFile()) {
|
|
23
|
+
const target = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
24
|
+
if (!target) return null;
|
|
25
|
+
gitDir = path.resolve(dir, target);
|
|
26
|
+
const common = path.join(gitDir, "commondir");
|
|
27
|
+
if (fs.existsSync(common))
|
|
28
|
+
gitDir = path.resolve(gitDir, fs.readFileSync(common, "utf8").trim());
|
|
29
|
+
}
|
|
30
|
+
return path.join(gitDir, "workit");
|
|
31
|
+
}
|
|
32
|
+
if (path.dirname(dir) === dir) return null;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const standingRevFile = (store: string): string => path.join(store, "standing", "rev");
|
|
40
|
+
|
|
41
|
+
/** One file per agent: a host session, or one subagent of it. */
|
|
42
|
+
export const standingSeenFile = (store: string, session: string, agentId: string | null): string =>
|
|
43
|
+
path.join(
|
|
44
|
+
store,
|
|
45
|
+
"standing",
|
|
46
|
+
"seen",
|
|
47
|
+
`${createHash("sha256")
|
|
48
|
+
.update(`${session}\0${agentId ?? ""}`)
|
|
49
|
+
.digest("hex")
|
|
50
|
+
.slice(0, 32)}.json`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export const readStandingRev = (store: string): string | null => {
|
|
54
|
+
try {
|
|
55
|
+
return fs.readFileSync(standingRevFile(store), "utf8").trim() || null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const readStandingSeen = (file: string): StandingSeen => {
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as { rev?: unknown; ids?: unknown };
|
|
64
|
+
return {
|
|
65
|
+
rev: typeof value.rev === "string" ? value.rev : null,
|
|
66
|
+
ids: Array.isArray(value.ids)
|
|
67
|
+
? value.ids.filter((id): id is string => typeof id === "string")
|
|
68
|
+
: [],
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return { rev: null, ids: [] };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Fails open: without the file the agent is offered the same delta again. */
|
|
76
|
+
export const writeStandingSeen = (file: string, seen: StandingSeen): void => {
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
79
|
+
fs.writeFileSync(file, JSON.stringify(seen));
|
|
80
|
+
} catch {
|
|
81
|
+
// An unwritable store only repeats the delta.
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const bumpStandingRev = (store: string, rev: string): void => {
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(path.join(store, "standing"), { recursive: true });
|
|
88
|
+
fs.writeFileSync(standingRevFile(store), `${rev}\n`);
|
|
89
|
+
} catch {
|
|
90
|
+
// Running workers miss the delta; briefs rendered later still carry it.
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Has this agent not yet seen the store's current standing revision? */
|
|
95
|
+
export const standingPending = (cwd: string, session: string, agentId: string | null): boolean => {
|
|
96
|
+
const store = gitStoreDir(cwd);
|
|
97
|
+
const rev = store ? readStandingRev(store) : null;
|
|
98
|
+
return rev !== null && readStandingSeen(standingSeenFile(store!, session, agentId)).rev !== rev;
|
|
99
|
+
};
|