@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,173 @@
|
|
|
1
|
+
import { cpSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const WORKIT_METHOD_SKILLS = [
|
|
5
|
+
"workit-shape",
|
|
6
|
+
"workit-implement",
|
|
7
|
+
"workit-review",
|
|
8
|
+
"workit-debug",
|
|
9
|
+
"workit-ship",
|
|
10
|
+
"workit-continue",
|
|
11
|
+
"workit-bdd",
|
|
12
|
+
"workit-test-audit",
|
|
13
|
+
"workit-deslop",
|
|
14
|
+
"workit-fanout",
|
|
15
|
+
"workit-verify-app",
|
|
16
|
+
"workit-retro",
|
|
17
|
+
"workit-architecture",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type WorkitSkill = (typeof WORKIT_METHOD_SKILLS)[number];
|
|
21
|
+
|
|
22
|
+
/** wk- slash aliases (one per skill): alias → method skill. An alias routes
|
|
23
|
+
* through policy to model skills; an alias never calls another alias. */
|
|
24
|
+
export const WORKIT_SKILL_ALIASES = {
|
|
25
|
+
"wk-shape": "workit-shape",
|
|
26
|
+
"wk-implement": "workit-implement",
|
|
27
|
+
"wk-review": "workit-review",
|
|
28
|
+
"wk-debug": "workit-debug",
|
|
29
|
+
"wk-ship": "workit-ship",
|
|
30
|
+
"wk-continue": "workit-continue",
|
|
31
|
+
"wk-bdd": "workit-bdd",
|
|
32
|
+
"wk-test-audit": "workit-test-audit",
|
|
33
|
+
"wk-deslop": "workit-deslop",
|
|
34
|
+
"wk-fanout": "workit-fanout",
|
|
35
|
+
"wk-verify-app": "workit-verify-app",
|
|
36
|
+
"wk-retro": "workit-retro",
|
|
37
|
+
"wk-architecture": "workit-architecture",
|
|
38
|
+
} as const satisfies Record<string, WorkitSkill>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The words a user types that should load each skill. Each skill's
|
|
42
|
+
* description and its bootstrap routing line carry every one of them, and no
|
|
43
|
+
* two skills share one (checked by test), so a trigger routes to one skill.
|
|
44
|
+
*/
|
|
45
|
+
export const WORKIT_SKILL_TRIGGERS: Readonly<Record<WorkitSkill, readonly string[]>> = {
|
|
46
|
+
"workit-shape": ["brainstorm", "plan", "spec", "grill", "should we"],
|
|
47
|
+
"workit-implement": ["implement", "build", "add a feature"],
|
|
48
|
+
"workit-review": ["review", "blast radius"],
|
|
49
|
+
"workit-debug": ["bug", "broken", "flaky", "regression"],
|
|
50
|
+
"workit-ship": ["ship", "babysit", "CI", "merge"],
|
|
51
|
+
"workit-continue": ["resume", "pick up", "handoff", "interruption"],
|
|
52
|
+
"workit-bdd": ["BDD", "TDD", "acceptance criteria", "Given/When/Then"],
|
|
53
|
+
"workit-test-audit": ["test audit", "tautology", "weak tests"],
|
|
54
|
+
"workit-deslop": ["deslop", "slop", "dead code"],
|
|
55
|
+
"workit-fanout": ["fan out", "parallelize", "parallel agents", "swarm"],
|
|
56
|
+
"workit-verify-app": ["verify the app", "smoke test", "prove it works"],
|
|
57
|
+
"workit-retro": ["retro"],
|
|
58
|
+
"workit-architecture": ["architecture"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The `description:` frontmatter value of a SKILL.md (one line, unquoted). */
|
|
62
|
+
export const skillDescription = (skillMd: string): string => {
|
|
63
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd)?.[1] ?? "";
|
|
64
|
+
const line = /^description:\s*(.+)$/m.exec(frontmatter)?.[1] ?? "";
|
|
65
|
+
return line.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A generated Cursor slash command for one alias; `commands/` is committed
|
|
69
|
+
* because Cursor discovers the plugin from git, so the build writes these and
|
|
70
|
+
* a drift test compares them to the committed copies. */
|
|
71
|
+
export const cursorCommandText = (alias: string, skill: string, description: string): string =>
|
|
72
|
+
`# /${alias}\n\nLoad and apply the bundled \`${skill}\` skill. ${description}\n\nExtra context: $ARGUMENTS\n`;
|
|
73
|
+
|
|
74
|
+
/** The hosts whose build writes a copy of the skills. */
|
|
75
|
+
export type SkillHost = "claude-code" | "cursor" | "codex" | "opencode" | "pi";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How an agent on `host` loads another Workit skill. A skill named only in
|
|
79
|
+
* prose does not reliably load; naming the host's own loading mechanism does
|
|
80
|
+
* (mattpocock/skills 1.3.0). Claude Code namespaces plugin skills, so
|
|
81
|
+
* `workit-bdd` is `workit:bdd` there.
|
|
82
|
+
*/
|
|
83
|
+
export const skillLoadWording = (host: SkillHost, skill: string): string => {
|
|
84
|
+
switch (host) {
|
|
85
|
+
case "claude-code":
|
|
86
|
+
return `call the Skill tool with \`workit:${skill.replace(/^workit-/, "")}\``;
|
|
87
|
+
case "opencode":
|
|
88
|
+
return `call the skill tool with \`${skill}\``;
|
|
89
|
+
default:
|
|
90
|
+
return `read the \`${skill}\` skill's SKILL.md and follow it`;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** A canonical cross-skill reference: a method skill's name alone in parentheses. */
|
|
95
|
+
const SKILL_REFERENCE = /\((workit-[a-z-]+)\)/g;
|
|
96
|
+
|
|
97
|
+
/** A canonical skill file with each cross-skill reference in the host's load wording. */
|
|
98
|
+
export const renderSkillText = (text: string, host: SkillHost): string =>
|
|
99
|
+
text.replace(SKILL_REFERENCE, (match, name: string) =>
|
|
100
|
+
(WORKIT_METHOD_SKILLS as readonly string[]).includes(name)
|
|
101
|
+
? `(${skillLoadWording(host, name)})`
|
|
102
|
+
: match,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** Copy one canonical skill directory to `out`, rendering its Markdown for `host`. */
|
|
106
|
+
export const copySkillForHost = (source: string, out: string, host: SkillHost): void => {
|
|
107
|
+
cpSync(source, out, { recursive: true });
|
|
108
|
+
const pending = [out];
|
|
109
|
+
while (pending.length > 0) {
|
|
110
|
+
const dir = pending.pop()!;
|
|
111
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
112
|
+
const file = path.join(dir, entry.name);
|
|
113
|
+
if (entry.isDirectory()) pending.push(file);
|
|
114
|
+
else if (entry.name.endsWith(".md"))
|
|
115
|
+
writeFileSync(file, renderSkillText(readFileSync(file, "utf8"), host));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const skillManifestNames = (root: string): string[] =>
|
|
121
|
+
existsSync(root)
|
|
122
|
+
? readdirSync(root)
|
|
123
|
+
.filter((name) => existsSync(path.join(root, name, "SKILL.md")))
|
|
124
|
+
.toSorted()
|
|
125
|
+
: [];
|
|
126
|
+
|
|
127
|
+
export const validateSkillManifests = (
|
|
128
|
+
root: string,
|
|
129
|
+
expected: readonly string[],
|
|
130
|
+
label: string,
|
|
131
|
+
): string | null => {
|
|
132
|
+
const actual = skillManifestNames(root);
|
|
133
|
+
const missing = expected.filter((name) => !actual.includes(name));
|
|
134
|
+
const extra = actual.filter((name) => !expected.includes(name));
|
|
135
|
+
return missing.length === 0 && extra.length === 0
|
|
136
|
+
? null
|
|
137
|
+
: `${label} mismatch at ${root} (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const validateCursorSkills = (
|
|
141
|
+
pluginDir: string,
|
|
142
|
+
expected: readonly string[] = WORKIT_METHOD_SKILLS,
|
|
143
|
+
): string | null => {
|
|
144
|
+
const workit = validateSkillManifests(
|
|
145
|
+
path.join(pluginDir, "skills"),
|
|
146
|
+
expected,
|
|
147
|
+
"Cursor Workit skills",
|
|
148
|
+
);
|
|
149
|
+
if (workit) return workit;
|
|
150
|
+
const pending = [path.join(pluginDir, "skills")];
|
|
151
|
+
while (pending.length > 0) {
|
|
152
|
+
const dir = pending.pop()!;
|
|
153
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
154
|
+
const file = path.join(dir, entry.name);
|
|
155
|
+
if (entry.isDirectory()) pending.push(file);
|
|
156
|
+
else if (
|
|
157
|
+
(statSync(file).mode & 0o111) !== 0 ||
|
|
158
|
+
readFileSync(file).subarray(0, 2).toString("latin1") === "#!"
|
|
159
|
+
) {
|
|
160
|
+
return `Cursor vendor contains active file: ${file}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (import.meta.main) {
|
|
168
|
+
const error = validateCursorSkills(process.argv[2] ?? "");
|
|
169
|
+
if (error) {
|
|
170
|
+
process.stderr.write(`${error}\n`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { canonicalJson } from "./task-contract";
|
|
7
|
+
import { checkoutSlug, resolveStore } from "../store/paths";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ownership rules for the workit store's locks: a checkout's
|
|
11
|
+
* `checkouts/<slug>/metadata.lock` and each task's `tasks/<id>/lock`.
|
|
12
|
+
*
|
|
13
|
+
* The lock is a short mutex around one store mutation (or one managed effect).
|
|
14
|
+
* A lock whose owner is gone is reclaimed automatically; a lock whose owner is
|
|
15
|
+
* alive is contention, which callers report as retryable `busy`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type MetadataLock = {
|
|
19
|
+
pid: number;
|
|
20
|
+
processStart: string | null;
|
|
21
|
+
host: string;
|
|
22
|
+
nonce: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const metadataLockSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
pid: z.number().int().nonnegative().safe(),
|
|
28
|
+
processStart: z.string().nullable(),
|
|
29
|
+
host: z.string().min(1),
|
|
30
|
+
nonce: z.string().min(1),
|
|
31
|
+
/** Written by ≤4.x managed external actions; read and ignored (D17). */
|
|
32
|
+
externalAction: z.literal(true).optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
|
|
36
|
+
/** A lock from another host cannot be checked for liveness; trust it this long. */
|
|
37
|
+
export const FOREIGN_LOCK_TTL_MS = 10 * 60_000;
|
|
38
|
+
/** An empty or unparseable lock is a writer mid-create; after this it is debris. */
|
|
39
|
+
export const UNREADABLE_LOCK_TTL_MS = 30_000;
|
|
40
|
+
/** A reclaim guard lives for microseconds; one older than this was abandoned. */
|
|
41
|
+
export const RECLAIM_GUARD_TTL_MS = 30_000;
|
|
42
|
+
/**
|
|
43
|
+
* Total time a mutation waits for a live holder before returning `busy`. The
|
|
44
|
+
* wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
|
|
45
|
+
* the short default; the CLI raises it for its own process.
|
|
46
|
+
*/
|
|
47
|
+
export const IN_PROCESS_LOCK_TIMEOUT_MS = 250;
|
|
48
|
+
let defaultLockTimeoutMs = IN_PROCESS_LOCK_TIMEOUT_MS;
|
|
49
|
+
export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
|
|
50
|
+
/**
|
|
51
|
+
* The wait for a lock that a CLI process may hold longer (the ledger's
|
|
52
|
+
* network-filesystem lock): `cliMs` once the process raised the default
|
|
53
|
+
* (the CLI does), else the in-process cap.
|
|
54
|
+
*/
|
|
55
|
+
export const hostAwareLockTimeout = (cliMs: number): number =>
|
|
56
|
+
defaultLockTimeoutMs > IN_PROCESS_LOCK_TIMEOUT_MS ? cliMs : IN_PROCESS_LOCK_TIMEOUT_MS;
|
|
57
|
+
export const setDefaultLockTimeout = (ms: number): void => {
|
|
58
|
+
defaultLockTimeoutMs = ms;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const parseMetadataLock = (raw: string): MetadataLock => {
|
|
62
|
+
let value: unknown;
|
|
63
|
+
try {
|
|
64
|
+
value = JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
|
|
67
|
+
}
|
|
68
|
+
const parsed = metadataLockSchema.safeParse(value);
|
|
69
|
+
if (!parsed.success)
|
|
70
|
+
throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
|
|
71
|
+
return parsed.data;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Lenient variant for the acquire loop: an unreadable lock is classified by age. */
|
|
75
|
+
export const parseMetadataLockOrNull = (raw: string): MetadataLock | null => {
|
|
76
|
+
try {
|
|
77
|
+
return parseMetadataLock(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
|
|
84
|
+
const parsed = metadataLockSchema.safeParse(left);
|
|
85
|
+
return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Field 22 (starttime) of /proc/<pid>/stat; parsed after the last ")" so a comm with spaces cannot shift it. */
|
|
89
|
+
export const parseProcStatStart = (stat: string): string | null => {
|
|
90
|
+
const close = stat.lastIndexOf(")");
|
|
91
|
+
if (close < 0) return null;
|
|
92
|
+
// After ")": field 3 (state) is index 0, so field 22 is index 19.
|
|
93
|
+
return (
|
|
94
|
+
stat
|
|
95
|
+
.slice(close + 1)
|
|
96
|
+
.trim()
|
|
97
|
+
.split(/\s+/)[19] ?? null
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** What processStartOf reads; tests inject a platform and readers. */
|
|
102
|
+
export type ProcessStartDeps = {
|
|
103
|
+
platform?: NodeJS.Platform;
|
|
104
|
+
/** The text of /proc/<pid>/stat, or null. */
|
|
105
|
+
readStat?: (pid: number) => string | null;
|
|
106
|
+
/** `ps -o lstart= -p <pid>` output, or null. */
|
|
107
|
+
psStart?: (pid: number) => string | null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const readProcStat = (pid: number): string | null => {
|
|
111
|
+
try {
|
|
112
|
+
return fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const psStart = (pid: number): string | null => {
|
|
119
|
+
try {
|
|
120
|
+
// C locale: lstart is compared as a string across processes, and a holder
|
|
121
|
+
// and a waiter in different locales would otherwise disagree on a live pid.
|
|
122
|
+
const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
timeout: 1_000,
|
|
125
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
126
|
+
});
|
|
127
|
+
return run.status === 0 ? run.stdout.trim() || null : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Start time of `pid`, so a recycled pid is told apart: /proc on Linux, `ps -o lstart=` on macOS and FreeBSD. */
|
|
134
|
+
export const processStartOf = (pid: number, deps: ProcessStartDeps = {}): string | null => {
|
|
135
|
+
const platform = deps.platform ?? process.platform;
|
|
136
|
+
if (platform === "linux") {
|
|
137
|
+
const stat = (deps.readStat ?? readProcStat)(pid);
|
|
138
|
+
return stat ? parseProcStatStart(stat) : null;
|
|
139
|
+
}
|
|
140
|
+
if (platform === "darwin" || platform === "freebsd") return (deps.psStart ?? psStart)(pid);
|
|
141
|
+
return null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const readTrimmed = (file: string): string | null => {
|
|
145
|
+
try {
|
|
146
|
+
return fs.readFileSync(file, "utf8").trim() || null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Identity of the pid space this process lives in: hostname plus, on Linux,
|
|
154
|
+
* the pid-namespace inode and boot id. Containers that share the hostname
|
|
155
|
+
* (`--network host`) but not the pid namespace get a different identity, so
|
|
156
|
+
* their pids are never checked against this process table. Folded into the
|
|
157
|
+
* existing `host` string so older Workit versions still parse the lock.
|
|
158
|
+
*/
|
|
159
|
+
let cachedLockHost: string | null = null;
|
|
160
|
+
export const localLockHost = (): string => {
|
|
161
|
+
if (cachedLockHost !== null) return cachedLockHost;
|
|
162
|
+
let pidns: string | null = null;
|
|
163
|
+
try {
|
|
164
|
+
pidns = /\[(\d+)\]/.exec(fs.readlinkSync("/proc/self/ns/pid"))?.[1] ?? null;
|
|
165
|
+
} catch {}
|
|
166
|
+
const boot = readTrimmed("/proc/sys/kernel/random/boot_id");
|
|
167
|
+
cachedLockHost = pidns || boot ? `${hostname()}#${pidns ?? "?"}:${boot ?? "?"}` : hostname();
|
|
168
|
+
return cachedLockHost;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const pidAlive = (pid: number): boolean => {
|
|
172
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, 0);
|
|
175
|
+
return true;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// EPERM: the process exists but belongs to another user.
|
|
178
|
+
return (error as { code?: unknown }).code === "EPERM";
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export type LockOwnerState = {
|
|
183
|
+
/** live: wait. stale: reclaim. unknown: wait (cannot prove the owner is gone). */
|
|
184
|
+
state: "live" | "stale" | "unknown";
|
|
185
|
+
reason: string;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export type ClassifyOptions = {
|
|
189
|
+
/**
|
|
190
|
+
* Task-store locks only: a holder keeps one for a single mutation, so a
|
|
191
|
+
* same-host lock past FOREIGN_LOCK_TTL_MS whose start time cannot be
|
|
192
|
+
* compared is taken as a reused pid (Windows has no start time). Never for
|
|
193
|
+
* the stack lock, which a long synchronous rebase holds without heartbeat.
|
|
194
|
+
*/
|
|
195
|
+
reclaimUnverifiedAfterTtl?: boolean;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** How task-store locks (and doctor, which inspects them) classify an owner. */
|
|
199
|
+
export const TASK_STORE_LOCK: ClassifyOptions = { reclaimUnverifiedAfterTtl: true };
|
|
200
|
+
|
|
201
|
+
export const classifyLockOwner = (
|
|
202
|
+
payload: unknown,
|
|
203
|
+
ageMs: number | null,
|
|
204
|
+
localHost: string = localLockHost(),
|
|
205
|
+
options: ClassifyOptions = {},
|
|
206
|
+
): LockOwnerState => {
|
|
207
|
+
const lock = metadataLockSchema.safeParse(payload);
|
|
208
|
+
if (!lock.success)
|
|
209
|
+
return ageMs !== null && ageMs > UNREADABLE_LOCK_TTL_MS
|
|
210
|
+
? { state: "stale", reason: "unreadable lock left behind" }
|
|
211
|
+
: { state: "unknown", reason: "lock is being written" };
|
|
212
|
+
const { pid, processStart, host } = lock.data;
|
|
213
|
+
// Same host and pid namespace but another boot: the machine rebooted since
|
|
214
|
+
// the lock was taken, so its owner cannot still be running.
|
|
215
|
+
const [lockName, lockSpace] = host.split("#");
|
|
216
|
+
const [localName, localSpace] = localHost.split("#");
|
|
217
|
+
if (lockSpace && localSpace && lockName === localName) {
|
|
218
|
+
const [lockNs, lockBoot] = lockSpace.split(":");
|
|
219
|
+
const [localNs, localBoot] = localSpace.split(":");
|
|
220
|
+
if (lockNs === localNs && lockNs !== "?" && lockBoot !== "?" && lockBoot !== localBoot)
|
|
221
|
+
return { state: "stale", reason: "lock was taken before this machine rebooted" };
|
|
222
|
+
}
|
|
223
|
+
// Another host, another pid namespace, or a lock written by an older Workit
|
|
224
|
+
// without namespace identity: its pid cannot be checked here.
|
|
225
|
+
if (host !== localHost)
|
|
226
|
+
return ageMs !== null && ageMs > FOREIGN_LOCK_TTL_MS
|
|
227
|
+
? { state: "stale", reason: `lock from ${host} is older than its TTL` }
|
|
228
|
+
: { state: "unknown", reason: `lock is held from ${host}; its pid cannot be checked here` };
|
|
229
|
+
if (!pidAlive(pid)) return { state: "stale", reason: `pid ${pid} is not running` };
|
|
230
|
+
const currentStart = processStartOf(pid);
|
|
231
|
+
if (processStart !== null && currentStart !== null && processStart !== currentStart)
|
|
232
|
+
return { state: "stale", reason: `pid ${pid} now belongs to a different process` };
|
|
233
|
+
// Without both start times (Windows has none) a reused pid looks alive. A
|
|
234
|
+
// task-store holder keeps the lock for one mutation, never minutes, so past
|
|
235
|
+
// the TTL the pid is taken to be reused and the crashed writer's lock is
|
|
236
|
+
// reclaimed (opt-in: see ClassifyOptions).
|
|
237
|
+
if (
|
|
238
|
+
options.reclaimUnverifiedAfterTtl === true &&
|
|
239
|
+
(processStart === null || currentStart === null) &&
|
|
240
|
+
ageMs !== null &&
|
|
241
|
+
ageMs > FOREIGN_LOCK_TTL_MS
|
|
242
|
+
)
|
|
243
|
+
return {
|
|
244
|
+
state: "stale",
|
|
245
|
+
reason: `pid ${pid} is running but the lock is older than its TTL and the pid's start time cannot be checked (reused pid)`,
|
|
246
|
+
};
|
|
247
|
+
return { state: "live", reason: `held by running pid ${pid}` };
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const ageOf = (file: string, nowMs: number): number | null => {
|
|
251
|
+
try {
|
|
252
|
+
return nowMs - fs.lstatSync(file).mtimeMs;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** A checkout's lock in the task store (see core/task-store.ts). */
|
|
259
|
+
export const lockPathFor = (root: string): string => {
|
|
260
|
+
let real = path.resolve(root);
|
|
261
|
+
try {
|
|
262
|
+
real = fs.realpathSync(real);
|
|
263
|
+
} catch {}
|
|
264
|
+
const location = resolveStore(real);
|
|
265
|
+
const dir = location instanceof Error ? path.join(real, ".workit") : location.dir;
|
|
266
|
+
return path.join(dir, "checkouts", checkoutSlug(real), "metadata.lock");
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** Remove a reclaim guard abandoned by a crashed reclaimer. Returns true when removed. */
|
|
270
|
+
export const clearAbandonedReclaimGuard = (lockPath: string, nowMs = Date.now()): boolean => {
|
|
271
|
+
const guard = `${lockPath}.reclaim`;
|
|
272
|
+
const age = ageOf(guard, nowMs);
|
|
273
|
+
if (age === null || age <= RECLAIM_GUARD_TTL_MS) return false;
|
|
274
|
+
try {
|
|
275
|
+
fs.rmdirSync(guard);
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export type MetadataLockStatus = {
|
|
283
|
+
path: string;
|
|
284
|
+
present: boolean;
|
|
285
|
+
owner: MetadataLock | null;
|
|
286
|
+
state: LockOwnerState["state"] | "absent";
|
|
287
|
+
reason: string;
|
|
288
|
+
guard: "absent" | "fresh" | "abandoned";
|
|
289
|
+
/** Exact lock bytes that were classified (for compare-before-remove). */
|
|
290
|
+
raw?: string;
|
|
291
|
+
/** Age of the lock file in milliseconds, when present. */
|
|
292
|
+
ageMs?: number | null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** Read-only inspection of a checkout's metadata lock (doctor surface). */
|
|
296
|
+
export const inspectMetadataLock = (root: string, nowMs = Date.now()): MetadataLockStatus => {
|
|
297
|
+
const lockPath = lockPathFor(root);
|
|
298
|
+
const guardAge = ageOf(`${lockPath}.reclaim`, nowMs);
|
|
299
|
+
const guard =
|
|
300
|
+
guardAge === null ? "absent" : guardAge > RECLAIM_GUARD_TTL_MS ? "abandoned" : "fresh";
|
|
301
|
+
let raw: string;
|
|
302
|
+
try {
|
|
303
|
+
raw = fs.readFileSync(lockPath, "utf8");
|
|
304
|
+
} catch {
|
|
305
|
+
return {
|
|
306
|
+
path: lockPath,
|
|
307
|
+
present: false,
|
|
308
|
+
owner: null,
|
|
309
|
+
state: "absent",
|
|
310
|
+
reason: "no lock",
|
|
311
|
+
guard,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const owner = parseMetadataLockOrNull(raw);
|
|
315
|
+
const ageMs = ageOf(lockPath, nowMs);
|
|
316
|
+
// The task store's own reclaim rule, so doctor reports what a write will do.
|
|
317
|
+
const verdict = classifyLockOwner(owner, ageMs, localLockHost(), TASK_STORE_LOCK);
|
|
318
|
+
return { path: lockPath, present: true, owner, ...verdict, guard, raw, ageMs };
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export type ClearLockOutcome = MetadataLockStatus & {
|
|
322
|
+
cleared: boolean;
|
|
323
|
+
guardCleared: boolean;
|
|
324
|
+
/** Why the lock was kept, when it was present and not cleared. */
|
|
325
|
+
skipped?: string;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Clear a stale metadata lock (or, with `force`, any lock) and an abandoned
|
|
330
|
+
* reclaim guard. Removal holds the same `.reclaim` guard that writers take
|
|
331
|
+
* before reclaiming, so no writer can replace the lock between the final
|
|
332
|
+
* byte check and the unlink; a fresh guard means a reclaim is already in
|
|
333
|
+
* progress and the lock is left alone.
|
|
334
|
+
*/
|
|
335
|
+
export const clearStaleMetadataLock = (
|
|
336
|
+
root: string,
|
|
337
|
+
options: { force?: boolean; nowMs?: number } = {},
|
|
338
|
+
): ClearLockOutcome => {
|
|
339
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
340
|
+
const status = inspectMetadataLock(root, nowMs);
|
|
341
|
+
const guardCleared = status.guard === "abandoned" && clearAbandonedReclaimGuard(status.path);
|
|
342
|
+
const outcome = { ...status, cleared: false, guardCleared };
|
|
343
|
+
if (!status.present) return outcome;
|
|
344
|
+
if (status.state !== "stale" && !options.force) return { ...outcome, skipped: status.reason };
|
|
345
|
+
const guard = `${status.path}.reclaim`;
|
|
346
|
+
try {
|
|
347
|
+
fs.mkdirSync(guard);
|
|
348
|
+
} catch {
|
|
349
|
+
return { ...outcome, skipped: "a reclaim is in progress" };
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const before = fs.readFileSync(status.path, "utf8");
|
|
353
|
+
if (before !== status.raw) return { ...outcome, skipped: "the lock changed" };
|
|
354
|
+
if (!options.force) {
|
|
355
|
+
const verdict = classifyLockOwner(
|
|
356
|
+
parseMetadataLockOrNull(before),
|
|
357
|
+
ageOf(status.path, nowMs),
|
|
358
|
+
localLockHost(),
|
|
359
|
+
TASK_STORE_LOCK,
|
|
360
|
+
);
|
|
361
|
+
if (verdict.state !== "stale") return { ...outcome, skipped: verdict.reason };
|
|
362
|
+
}
|
|
363
|
+
if (fs.readFileSync(status.path, "utf8") !== before)
|
|
364
|
+
return { ...outcome, skipped: "the lock changed" };
|
|
365
|
+
fs.rmSync(status.path);
|
|
366
|
+
return { ...outcome, cleared: true };
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return { ...outcome, skipped: `could not clear: ${String(error)}` };
|
|
369
|
+
} finally {
|
|
370
|
+
try {
|
|
371
|
+
fs.rmdirSync(guard);
|
|
372
|
+
} catch {}
|
|
373
|
+
}
|
|
374
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Declared support matrix for published Workit artifacts (RR-10/PT-11/PT-12).
|
|
2
|
+
// Single source of truth for the pinned toolchain and host versions; the CI
|
|
3
|
+
// workflow, package engines, and lockfiles must stay in sync (enforced by
|
|
4
|
+
// test/artifacts/manifests.test.ts and test/artifacts/packed-runtime.test.ts).
|
|
5
|
+
// Deno is intentionally not part of the matrix (PT-12): nothing advertises a
|
|
6
|
+
// host that has no executable artifact/test evidence.
|
|
7
|
+
export const SUPPORT_MATRIX = {
|
|
8
|
+
bun: "1.4.4",
|
|
9
|
+
node: { minimum: "24", current: "24.20.0" },
|
|
10
|
+
// OpenCode V2 plugin API only: the entry exports `setup()` (Workit 3.0
|
|
11
|
+
// retired the V1 `server()` adapter), so 1.x hosts cannot load it. 2.0.18 is
|
|
12
|
+
// the `@opencode/plugin` contract the adapter is built and tested against.
|
|
13
|
+
opencode: { minimum: "2.0.18", current: "2.0.18" },
|
|
14
|
+
// Codex is a qualification host, not a runtime dependency: the CLI version
|
|
15
|
+
// below is the one live qualification evidence covers. The doctor warns when
|
|
16
|
+
// an installed CLI drifts ahead so a fresh install never silently outruns
|
|
17
|
+
// the qualification pin.
|
|
18
|
+
codex: { cli: "0.153.4", desktopPackage: "26.901.20858", bundledCodexCli: "0.153.0-alpha.5" },
|
|
19
|
+
// Claude Code is a qualification host too: CI runs `claude plugin validate
|
|
20
|
+
// --strict` with exactly this CLI, and the hook output schema fixture
|
|
21
|
+
// (test/fixtures/claude-code-schemas) was transcribed from this binary.
|
|
22
|
+
claudeCode: { cli: "2.1.288" },
|
|
23
|
+
os: ["ubuntu-latest", "macos-latest", "windows-latest"],
|
|
24
|
+
} as const;
|