@ikon85/agent-workflow-kit 0.27.0 → 0.28.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.
Files changed (59) hide show
  1. package/.agents/skills/ask-matt/SKILL.md +2 -2
  2. package/.agents/skills/grill-me/SKILL.md +1 -1
  3. package/.agents/skills/grill-with-docs/SKILL.md +1 -1
  4. package/.agents/skills/kit-update/SKILL.md +12 -0
  5. package/.agents/skills/orchestrate-wave/SKILL.md +68 -21
  6. package/.agents/skills/orchestrate-wave/references/builder-contract.md +6 -4
  7. package/.agents/skills/retro/SKILL.md +24 -1
  8. package/.agents/skills/scale-check/SKILL.md +2 -2
  9. package/.agents/skills/setup-workflow/SKILL.md +44 -1
  10. package/.agents/skills/setup-workflow/workflow-overview.md +5 -5
  11. package/.agents/skills/tdd/SKILL.md +68 -14
  12. package/.agents/skills/to-issues/SKILL.md +2 -2
  13. package/.agents/skills/verify-spike/SKILL.md +1 -1
  14. package/.agents/skills/wrapup/SKILL.md +14 -2
  15. package/.claude/hooks/drift-guard.py +5 -2
  16. package/.claude/hooks/kit-origin-edit-hint.py +64 -0
  17. package/.claude/skills/ask-matt/SKILL.md +2 -2
  18. package/.claude/skills/grill-me/SKILL.md +1 -1
  19. package/.claude/skills/grill-with-docs/SKILL.md +1 -1
  20. package/.claude/skills/kit-update/SKILL.md +12 -0
  21. package/.claude/skills/orchestrate-wave/SKILL.md +68 -21
  22. package/.claude/skills/orchestrate-wave/references/builder-contract.md +6 -4
  23. package/.claude/skills/retro/SKILL.md +23 -0
  24. package/.claude/skills/scale-check/SKILL.md +2 -2
  25. package/.claude/skills/setup-workflow/SKILL.md +44 -1
  26. package/.claude/skills/setup-workflow/workflow-overview.md +5 -5
  27. package/.claude/skills/skill-manifest.json +1 -1
  28. package/.claude/skills/tdd/SKILL.md +68 -14
  29. package/.claude/skills/to-issues/SKILL.md +2 -2
  30. package/.claude/skills/verify-spike/SKILL.md +1 -1
  31. package/.claude/skills/wrapup/SKILL.md +14 -2
  32. package/README.md +51 -7
  33. package/agent-workflow-kit.package.json +50 -34
  34. package/docs/adr/0001-consumer-divergence-policy.md +49 -0
  35. package/docs/agents/wave-anchor-template.md +1 -1
  36. package/package.json +1 -1
  37. package/scripts/board-sync.py +184 -5
  38. package/scripts/pr-body-check.py +34 -6
  39. package/scripts/pr_body_e2e.py +83 -0
  40. package/scripts/release-parity.mjs +34 -0
  41. package/scripts/release-parity.test.mjs +37 -0
  42. package/scripts/test_board_sync.py +208 -0
  43. package/scripts/test_census_backstop.py +56 -3
  44. package/scripts/test_orchestrate_wave_contract.py +116 -0
  45. package/scripts/test_pr_body_check.py +245 -0
  46. package/scripts/test_retro_wrapup_contract.py +111 -0
  47. package/scripts/test_tdd_contract.py +78 -0
  48. package/src/cli.mjs +49 -10
  49. package/src/commands/diff.mjs +5 -2
  50. package/src/commands/init.mjs +12 -0
  51. package/src/commands/own.mjs +16 -0
  52. package/src/commands/uninstall.mjs +8 -1
  53. package/src/commands/update.mjs +39 -11
  54. package/src/lib/bundle.mjs +4 -0
  55. package/src/lib/consumerPath.mjs +30 -0
  56. package/src/lib/manifest.mjs +18 -0
  57. package/src/lib/ownedDiff.mjs +88 -0
  58. package/src/lib/updateCandidate.mjs +14 -0
  59. package/src/lib/updateReconcile.mjs +45 -6
@@ -0,0 +1,111 @@
1
+ """Contract tests for retro enforcement and safe wrapup workflow chaining."""
2
+
3
+ from pathlib import Path
4
+ import re
5
+ import unittest
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ SURFACES = (".claude", ".agents")
10
+
11
+
12
+ def skill(surface: str, name: str) -> str:
13
+ return (ROOT / surface / "skills" / name / "SKILL.md").read_text(
14
+ encoding="utf-8"
15
+ )
16
+
17
+
18
+ def contract_text(surface: str, name: str) -> str:
19
+ """Return prose with Markdown emphasis removed for wording assertions."""
20
+ return re.sub(r"\s+", " ", skill(surface, name).replace("**", ""))
21
+
22
+
23
+ class RetroEnforcementContract(unittest.TestCase):
24
+ def test_mechanical_check_precedes_target_and_weight_ladder(self):
25
+ for surface in SURFACES:
26
+ with self.subTest(surface=surface):
27
+ text = contract_text(surface, "retro")
28
+ mechanical = text.index("### 3a. Mechanical enforcement check")
29
+ ladder = text.index("### 3b. Determine target + weight")
30
+ self.assertLess(mechanical, ladder)
31
+
32
+ def test_machine_checkable_findings_cannot_stop_at_prose(self):
33
+ required = (
34
+ "Could a machine decide this",
35
+ "implement the enforcement",
36
+ "tracked enforcement issue",
37
+ "explicit trade-off",
38
+ )
39
+ for surface in SURFACES:
40
+ with self.subTest(surface=surface):
41
+ text = contract_text(surface, "retro")
42
+ for phrase in required:
43
+ self.assertIn(phrase, text)
44
+
45
+ def test_upstream_or_own_and_exact_sanitized_approval_survive(self):
46
+ required = (
47
+ "generic or project-specific",
48
+ "recommend `own`",
49
+ "sanitized preview",
50
+ "explicitly approves that exact text",
51
+ "does not need to be the kit maintainer",
52
+ "docs/agents/skills/<skill>.md",
53
+ )
54
+ for surface in SURFACES:
55
+ with self.subTest(surface=surface):
56
+ text = contract_text(surface, "retro")
57
+ for phrase in required:
58
+ self.assertIn(phrase, text)
59
+
60
+
61
+ class WrapupChainingContract(unittest.TestCase):
62
+ def test_retro_is_model_invocable_on_both_surfaces(self):
63
+ for surface in SURFACES:
64
+ with self.subTest(surface=surface):
65
+ frontmatter = skill(surface, "retro").split("---", 2)[1]
66
+ self.assertIn("disable-model-invocation: false", frontmatter)
67
+
68
+ def test_affirmative_retro_gate_chains_without_landing(self):
69
+ required = (
70
+ "invoke the `retro` skill immediately in this run",
71
+ "land nothing in this run",
72
+ "fresh explicit `/wrapup` invocation",
73
+ )
74
+ for surface in SURFACES:
75
+ with self.subTest(surface=surface):
76
+ text = contract_text(surface, "wrapup")
77
+ for phrase in required:
78
+ self.assertIn(phrase, text)
79
+
80
+ def test_only_model_invocable_non_deploying_workflows_chain(self):
81
+ required = (
82
+ "model-invocable",
83
+ "non-deploying",
84
+ "return control to the user",
85
+ "state the reason",
86
+ )
87
+ for surface in SURFACES:
88
+ with self.subTest(surface=surface):
89
+ text = contract_text(surface, "wrapup")
90
+ for phrase in required:
91
+ self.assertIn(phrase, text)
92
+
93
+ def test_wrapup_remains_manual_and_program_propagation_remains_present(self):
94
+ for surface in SURFACES:
95
+ with self.subTest(surface=surface):
96
+ text = skill(surface, "wrapup")
97
+ self.assertIn("disable-model-invocation: true", text)
98
+ self.assertIn("user's `/wrapup` input IS the explicit", text)
99
+ self.assertIn("program-sync", text)
100
+ self.assertIn("Phasen-Gates", text)
101
+
102
+ def test_new_contract_is_reference_free(self):
103
+ for surface in SURFACES:
104
+ with self.subTest(surface=surface):
105
+ combined = skill(surface, "retro") + skill(surface, "wrapup")
106
+ self.assertNotIn("testreporter", combined.lower())
107
+ self.assertIsNone(re.search(r"#[0-9]{3,}", combined))
108
+
109
+
110
+ if __name__ == "__main__":
111
+ unittest.main(verbosity=2)
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Contract tests for existing-test-first TDD without weakening RED-first."""
3
+
4
+ from pathlib import Path
5
+ import re
6
+ import unittest
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ SURFACES = (
11
+ ROOT / ".claude" / "skills" / "tdd" / "SKILL.md",
12
+ ROOT / ".agents" / "skills" / "tdd" / "SKILL.md",
13
+ )
14
+ DECISIONS = ("REUSE", "EXTEND", "REPLACE", "NEW", "RETIRE", "NO-NEW-TEST")
15
+
16
+
17
+ class TddContractTest(unittest.TestCase):
18
+ def texts(self):
19
+ return {path: path.read_text(encoding="utf-8") for path in SURFACES}
20
+
21
+ def test_each_behavior_gets_exactly_one_existing_test_first_decision(self):
22
+ for path, text in self.texts().items():
23
+ with self.subTest(surface=path.relative_to(ROOT)):
24
+ self.assertIn("### 2. Existing-Test-First Decision", text)
25
+ self.assertIn("For each planned behavior, record exactly one", text)
26
+ for decision in DECISIONS:
27
+ self.assertEqual(
28
+ len(re.findall(rf"^- \*\*{re.escape(decision)}\*\*", text, re.MULTILINE)),
29
+ 1,
30
+ f"{decision} must have exactly one canonical definition",
31
+ )
32
+
33
+ def test_new_files_require_rejecting_the_nearest_owner_with_a_reason(self):
34
+ for path, text in self.texts().items():
35
+ with self.subTest(surface=path.relative_to(ROOT)):
36
+ self.assertIn("nearest existing owner", text)
37
+ self.assertIn("rejected with a reason", text)
38
+
39
+ def test_red_first_is_unconditional_for_executable_behavior_and_bug_fixes(self):
40
+ for path, text in self.texts().items():
41
+ with self.subTest(surface=path.relative_to(ROOT)):
42
+ self.assertIn(
43
+ "Every executable new behavior and bug fix must begin with a failing assertion",
44
+ text,
45
+ )
46
+ self.assertIn("REUSE and NO-NEW-TEST never bypass this RED-first invariant", text)
47
+ self.assertRegex(
48
+ text,
49
+ r"an already-green\s+assertion does not prove the requested change",
50
+ )
51
+
52
+ def test_retirement_keeps_negative_tests_only_for_durable_absence(self):
53
+ for path, text in self.texts().items():
54
+ with self.subTest(surface=path.relative_to(ROOT)):
55
+ self.assertIn("delete the tests that specified the retired behavior", text)
56
+ self.assertIn("only when absence itself is a durable", text)
57
+
58
+ def test_worked_matrix_and_counted_handoff_cover_all_decisions(self):
59
+ for path, text in self.texts().items():
60
+ with self.subTest(surface=path.relative_to(ROOT)):
61
+ matrix = re.search(
62
+ r"### Worked decision matrix\n(?P<body>.*?)(?=\n### |\n## )",
63
+ text,
64
+ re.DOTALL,
65
+ )
66
+ self.assertIsNotNone(matrix)
67
+ body = matrix.group("body")
68
+ for decision in DECISIONS:
69
+ self.assertRegex(body, rf"\|\s*{re.escape(decision)}\s*\|")
70
+ self.assertIn("list every behavior and its one decision", text)
71
+ self.assertIn(
72
+ "Reused X · Extended Y · New Z · Replaced/retired W",
73
+ text,
74
+ )
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()
package/src/cli.mjs CHANGED
@@ -6,7 +6,10 @@ import { init } from './commands/init.mjs';
6
6
  import { update } from './commands/update.mjs';
7
7
  import { diff } from './commands/diff.mjs';
8
8
  import { uninstall } from './commands/uninstall.mjs';
9
+ import { setOwnership } from './commands/own.mjs';
10
+ import { CONSUMER_ORIGIN, KIT_ORIGIN } from './lib/manifest.mjs';
9
11
  import { createCommandAdapter } from '../scripts/release-state.mjs';
12
+ import { installedIdentityFromDir } from '../scripts/release-parity.mjs';
10
13
 
11
14
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
15
  const consumerRoot = process.cwd();
@@ -20,6 +23,7 @@ const args = process.argv.slice(2);
20
23
  const cmd = args[0];
21
24
  const force = args.includes('--force');
22
25
  const yes = args.includes('--yes') || args.includes('-y');
26
+ const owned = args.includes('--owned');
23
27
 
24
28
  p.intro('agent-workflow-kit');
25
29
 
@@ -34,15 +38,11 @@ try {
34
38
  p.outro('Next: run /setup-workflow to fill the project layer + board profile. ' +
35
39
  'To enable the drift-guard hook, add .claude/hooks/drift-guard.py to your settings.json hooks.');
36
40
  } else if (cmd === 'diff') {
37
- const r = await diff({ kitRoot: KIT_ROOT, consumerRoot });
41
+ const r = await diff({ kitRoot: KIT_ROOT, consumerRoot, owned });
38
42
  printPlan(r);
39
43
  p.outro('Dry run — nothing written. Run `update` to apply.');
40
44
  } else if (cmd === 'update') {
41
- const decide = async (_action, path) => {
42
- if (yes) return true;
43
- const ok = await p.confirm({ message: `Upstream removed ${path} — delete it locally?` });
44
- return ok === true;
45
- };
45
+ const decide = (action, path) => decideUpdate(action, path, yes);
46
46
  const releaseIdentities = await readUpdateRelease();
47
47
  const r = await update({
48
48
  kitRoot: KIT_ROOT, consumerRoot, now: stamp(), decide, releaseIdentities,
@@ -64,8 +64,13 @@ try {
64
64
  if (!ok) { p.cancel('Aborted.'); process.exit(0); }
65
65
  const r = await uninstall({ consumerRoot });
66
66
  p.outro(`removed ${r.removed.length} · retained (edited/referenced) ${r.retained.length}`);
67
+ } else if (cmd === 'own' || cmd === 'disown') {
68
+ if (!args[1]) throw new Error(`Usage: agent-workflow-kit ${cmd} <path>`);
69
+ const origin = cmd === 'own' ? CONSUMER_ORIGIN : KIT_ORIGIN;
70
+ await setOwnership({ consumerRoot, path: args[1], origin });
71
+ p.outro(`${args[1]} is now ${origin}-owned`);
67
72
  } else {
68
- p.note('Usage: agent-workflow-kit <init|update|diff|uninstall> [--force] [--yes]');
73
+ p.note('Usage: agent-workflow-kit <init|update|diff|uninstall|own|disown> [<path>] [--force] [--yes] [--owned]');
69
74
  p.outro('');
70
75
  }
71
76
  } catch (err) {
@@ -75,20 +80,54 @@ try {
75
80
 
76
81
  function printPlan(r) {
77
82
  const lines = [];
78
- for (const k of ['added', 'updated', 'userModified', 'unchanged', 'deleted', 'keptDeleted'])
83
+ for (const k of [
84
+ 'added', 'updated', 'userModified', 'consumerOwned', 'unchanged',
85
+ 'deleted', 'keptDeleted', 'collisions',
86
+ ])
79
87
  if (r[k]?.length) lines.push(`${k}: ${r[k].length}`);
80
88
  if (r.conflicts?.length) lines.push(`conflicts: ${r.conflicts.length}`);
89
+ for (const owned of r.ownedDiffs ?? []) {
90
+ lines.push(`${owned.state} ${owned.path}`);
91
+ if (owned.binary) {
92
+ lines.push(` local: ${owned.local.size} bytes sha256:${owned.local.sha256}`);
93
+ lines.push(` upstream: ${owned.upstream.size} bytes sha256:${owned.upstream.sha256}`);
94
+ } else if (owned.diff) {
95
+ lines.push(owned.diff);
96
+ }
97
+ }
81
98
  p.note(lines.join('\n') || 'no changes', 'plan');
82
99
  }
83
100
 
101
+ async function decideUpdate(action, path, yes) {
102
+ if (action === 'delete') {
103
+ if (yes) return true;
104
+ return (await p.confirm({ message: `Upstream removed ${path} — delete it locally?` })) === true;
105
+ }
106
+ if (action === 'collision') {
107
+ if (yes) return 'replace';
108
+ const choice = await p.select({
109
+ message: `${path} already exists but is not tracked. Choose its ownership:`,
110
+ options: [
111
+ { value: 'keep-as-owned', label: 'Keep existing file as consumer-owned' },
112
+ { value: 'replace', label: 'Replace with kit file' },
113
+ ],
114
+ });
115
+ if (p.isCancel(choice)) throw new Error(`collision decision cancelled for ${path}`);
116
+ return choice;
117
+ }
118
+ throw new Error(`unknown update decision action: ${action}`);
119
+ }
120
+
84
121
  async function readUpdateRelease() {
85
122
  const adapter = await createCommandAdapter({
86
123
  repoRoot: KIT_ROOT,
87
124
  env: { ...process.env, GH_REPO: 'iKon85/agent-workflow-kit' },
88
125
  });
89
126
  try {
90
- const local = (await adapter.local()).identity;
91
- return { local, npm: await adapter.npm(local), github: await adapter.github(local) };
127
+ // Re-packing an unpacked install is never byte-identical to the registry
128
+ // tarball the installed copy proves itself by content identity instead.
129
+ const installed = await installedIdentityFromDir(KIT_ROOT);
130
+ return { installed, npm: await adapter.npm(installed), github: await adapter.github(installed) };
92
131
  } finally {
93
132
  await adapter.dispose();
94
133
  }
@@ -1,6 +1,9 @@
1
1
  import { update } from './update.mjs';
2
+ import { ownedDiff } from '../lib/ownedDiff.mjs';
2
3
 
3
4
  /** Dry-run of `update`: classifies what an update would do, writes nothing. */
4
- export async function diff({ kitRoot, consumerRoot }) {
5
- return update({ kitRoot, consumerRoot, now: 'dry', dryRun: true });
5
+ export async function diff({ kitRoot, consumerRoot, owned = false }) {
6
+ const preview = await update({ kitRoot, consumerRoot, now: 'dry', dryRun: true });
7
+ if (!owned) return preview;
8
+ return { ...preview, ownedDiffs: await ownedDiff({ kitRoot, consumerRoot }) };
6
9
  }
@@ -8,6 +8,7 @@ import {
8
8
  readManifest, writeManifest, emptyConsumerManifest,
9
9
  filesForInstallRole, CONSUMER_INSTALL_ROLE,
10
10
  indexByPath,
11
+ CONSUMER_ORIGIN,
11
12
  PACKAGE_MANIFEST_NAME, CONSUMER_MANIFEST_NAME,
12
13
  } from '../lib/manifest.mjs';
13
14
 
@@ -27,12 +28,19 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
27
28
  if (!pkg) throw new Error('kit package manifest not found');
28
29
  const prior = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
29
30
  const tracked = new Set((prior?.installed ?? []).map((e) => e.path));
31
+ const consumerOwned = new Set(
32
+ (prior?.installed ?? []).filter((e) => e.origin === CONSUMER_ORIGIN).map((e) => e.path),
33
+ );
30
34
  const packageIdx = indexByPath(pkg, 'files');
31
35
 
32
36
  const result = { copied: [], skipped: [], seeded: [] };
33
37
  const installed = [];
34
38
 
35
39
  for (const entry of prior?.installed ?? []) {
40
+ if (entry.origin === CONSUMER_ORIGIN) {
41
+ installed.push(entry);
42
+ continue;
43
+ }
36
44
  const packageEntry = packageIdx.get(entry.path);
37
45
  if (packageEntry?.installRole === CONSUMER_INSTALL_ROLE || !packageEntry?.installRole) continue;
38
46
  if (!await exists(join(consumerRoot, entry.path))) continue;
@@ -41,6 +49,10 @@ export async function init({ kitRoot, consumerRoot, force = false }) {
41
49
 
42
50
  for (const f of filesForInstallRole(pkg)) {
43
51
  const dest = join(consumerRoot, f.path);
52
+ if (consumerOwned.has(f.path)) {
53
+ result.skipped.push(f.path);
54
+ continue;
55
+ }
44
56
  if (await exists(dest) && !tracked.has(f.path) && !force) {
45
57
  result.skipped.push(f.path); // pre-existing untracked → never-clobber
46
58
  continue;
@@ -0,0 +1,16 @@
1
+ import { join } from 'node:path';
2
+ import { validateConsumerFile } from '../lib/consumerPath.mjs';
3
+ import {
4
+ CONSUMER_MANIFEST_NAME, readManifest, withOrigin, writeManifest,
5
+ } from '../lib/manifest.mjs';
6
+
7
+ /** Mark one tracked consumer file as kit- or consumer-owned. */
8
+ export async function setOwnership({ consumerRoot, path, origin }) {
9
+ const manifestPath = join(consumerRoot, CONSUMER_MANIFEST_NAME);
10
+ const manifest = await readManifest(manifestPath);
11
+ if (!manifest) throw new Error('not initialised — run `init` first');
12
+ const next = withOrigin(manifest, path, origin);
13
+ await validateConsumerFile(consumerRoot, path);
14
+ await writeManifest(manifestPath, next);
15
+ return { path, origin };
16
+ }
@@ -2,7 +2,10 @@ import { access, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { sha256File } from '../lib/hash.mjs';
4
4
  import { hookReferenced } from '../lib/settings.mjs';
5
- import { writeManifest, readManifest, emptyConsumerManifest, CONSUMER_MANIFEST_NAME } from '../lib/manifest.mjs';
5
+ import {
6
+ writeManifest, readManifest, emptyConsumerManifest,
7
+ CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN,
8
+ } from '../lib/manifest.mjs';
6
9
 
7
10
  const exists = (p) => access(p).then(() => true, () => false);
8
11
 
@@ -22,6 +25,10 @@ export async function uninstall({ consumerRoot }) {
22
25
  for (const e of consumer.installed) {
23
26
  const dest = join(consumerRoot, e.path);
24
27
  if (!(await exists(dest))) continue; // already gone
28
+ if (e.origin === CONSUMER_ORIGIN) {
29
+ res.retained.push(e.path);
30
+ continue;
31
+ }
25
32
  const userEdited = (await sha256File(dest)) !== e.installedSha256;
26
33
  const referenced = e.kind === 'hook' && (await hookReferenced(consumerRoot, e.path));
27
34
  if (userEdited || referenced) {
@@ -1,6 +1,6 @@
1
1
  import { readFile, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { assertReleaseParity } from '../../scripts/release-parity.mjs';
3
+ import { assertConsumerReleaseParity } from '../../scripts/release-parity.mjs';
4
4
  import { activateCandidate, stageConsumer, verifyCandidate } from '../lib/updateCandidate.mjs';
5
5
  import { reconcile } from '../lib/updateReconcile.mjs';
6
6
  import {
@@ -32,24 +32,45 @@ export async function update(options) {
32
32
  const consumerManifestBefore = await readFile(consumerManifestPath);
33
33
 
34
34
  const decisions = new Map();
35
- const choose = async (_action, path) => {
36
- if (!decisions.has(path)) decisions.set(path, await decide('delete', path));
37
- return decisions.get(path);
35
+ const choosePreview = async (action, path) => {
36
+ if (action === 'collision') return undefined;
37
+ const key = decisionKey(action, path);
38
+ if (!decisions.has(key)) decisions.set(key, await decide(action, path));
39
+ return decisions.get(key);
38
40
  };
39
- const preview = await reconcile({ kitRoot, consumerRoot, decide: choose, dryRun: true });
41
+ const preview = await reconcile({ kitRoot, consumerRoot, decide: choosePreview, dryRun: true });
40
42
  await transition('preview');
41
- if (preview.deleted.length || preview.keptDeleted.length) await transition('awaiting_decision');
42
43
  if (dryRun) return { ...preview, state: 'preview', history };
43
44
  if (preview.conflicts.length) return terminal(preview, 'conflicted', history, transition);
44
- if (!hasUpstreamDelta(preview)) {
45
- return { ...await terminal(preview, 'applied', history, transition), status: 'current' };
45
+ const resolvedPreview = await resolvePreview({
46
+ kitRoot, consumerRoot, preview, decisions, decide, transition,
47
+ });
48
+ if (resolvedPreview.conflicts.length) {
49
+ return terminal(resolvedPreview, 'conflicted', history, transition);
50
+ }
51
+ if (!hasUpstreamDelta(resolvedPreview)) {
52
+ return { ...await terminal(resolvedPreview, 'applied', history, transition), status: 'current' };
46
53
  }
47
54
  return applyTransaction({
48
- kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
55
+ kitRoot, consumerRoot, pkg, preview: resolvedPreview, decisions, verify, signal, resumeFrom,
49
56
  consumerManifestBefore, history, transition,
50
57
  });
51
58
  }
52
59
 
60
+ async function resolvePreview({ kitRoot, consumerRoot, preview, decisions, decide, transition }) {
61
+ if (preview.deleted.length || preview.keptDeleted.length || preview.collisions.length) {
62
+ await transition('awaiting_decision');
63
+ }
64
+ for (const path of preview.collisions) {
65
+ decisions.set(decisionKey('collision', path), await decide('collision', path));
66
+ }
67
+ if (!preview.collisions.length) return preview;
68
+ return reconcile({
69
+ kitRoot, consumerRoot, dryRun: true,
70
+ decide: (action, path) => decisions.get(decisionKey(action, path)),
71
+ });
72
+ }
73
+
53
74
  async function applyTransaction(context) {
54
75
  const {
55
76
  kitRoot, consumerRoot, pkg, preview, decisions, verify, signal, resumeFrom,
@@ -59,11 +80,14 @@ async function applyTransaction(context) {
59
80
  let keepCandidate = false;
60
81
  try {
61
82
  await transition('staging');
83
+ if (candidateRoot && preview.collisionResolutions.length) {
84
+ throw new Error('collision-bearing candidate cannot be resumed safely');
85
+ }
62
86
  if (!candidateRoot) {
63
87
  candidateRoot = await stageConsumer(consumerRoot);
64
88
  await reconcile({
65
89
  kitRoot, consumerRoot: candidateRoot,
66
- decide: (_action, path) => decisions.get(path) === true,
90
+ decide: (action, path) => decisions.get(decisionKey(action, path)),
67
91
  });
68
92
  }
69
93
  await transition('verifying');
@@ -85,8 +109,12 @@ async function applyTransaction(context) {
85
109
  }
86
110
  }
87
111
 
112
+ function decisionKey(action, path) {
113
+ return `${action}\0${path}`;
114
+ }
115
+
88
116
  function verifyRelease(identities, kitVersion) {
89
- const release = assertReleaseParity(identities);
117
+ const release = assertConsumerReleaseParity(identities);
90
118
  if (release.name !== RELEASE_NAME) throw new Error(`invalid release origin: ${release.name}`);
91
119
  if (release.version !== kitVersion) {
92
120
  throw new Error(`release version ${release.version} does not match kit ${kitVersion}`);
@@ -34,6 +34,7 @@ export const HELPER_FILES = [
34
34
  { path: 'scripts/board-sync.py', kind: 'script', mode: 0o755 },
35
35
  { path: 'scripts/execute-ready-check.py', kind: 'script', mode: 0o755 },
36
36
  { path: 'scripts/pr-body-check.py', kind: 'script', mode: 0o755 },
37
+ { path: 'scripts/pr_body_e2e.py', kind: 'script', mode: 0o644 },
37
38
  // Mechanical executor for /wrapup (preflight/commit/land) — replaced the
38
39
  // Sonnet phase-2 subagent. Imports board_config + anchor_table
39
40
  // (both shipped above). Invokable CLI → 0o755.
@@ -91,6 +92,9 @@ export const HELPER_FILES = [
91
92
  { path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
92
93
  { path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
93
94
  { path: '.claude/hooks/slice-handoff-hint.py', kind: 'hook', mode: 0o755 },
95
+ // Advisory provenance hint for agent Edit/Write events. It reads the local
96
+ // consumer manifest once and fails open; setup-workflow owns activation.
97
+ { path: '.claude/hooks/kit-origin-edit-hint.py', kind: 'hook', mode: 0o755 },
94
98
  // Profile-driven non-blocking change-lifecycle advisories. The shell Stop
95
99
  // entry delegates to its sibling Python adapter; decisions stay in core.py.
96
100
  { path: 'scripts/workflow-advisories/core.py', kind: 'script', mode: 0o644 },
@@ -0,0 +1,30 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { isAbsolute, join, normalize, relative, resolve } from 'node:path';
3
+
4
+ /** Resolve a normalized repo-relative path and require a regular, non-symlink file. */
5
+ export async function validateConsumerFile(consumerRoot, path) {
6
+ if (typeof path !== 'string' || !path || path === '.' || isAbsolute(path) || normalize(path) !== path) {
7
+ throw new Error(`unsafe consumer path: ${path}`);
8
+ }
9
+ const root = resolve(consumerRoot);
10
+ const absolute = resolve(join(root, path));
11
+ const fromRoot = relative(root, absolute);
12
+ if (!fromRoot || fromRoot.startsWith(`..${separator()}`) || fromRoot === '..' || isAbsolute(fromRoot)) {
13
+ throw new Error(`unsafe consumer path: ${path}`);
14
+ }
15
+ let stat;
16
+ try {
17
+ stat = await lstat(absolute);
18
+ } catch (error) {
19
+ if (error.code === 'ENOENT') throw new Error(`unsafe consumer path (not a regular file): ${path}`);
20
+ throw error;
21
+ }
22
+ if (stat.isSymbolicLink() || !stat.isFile()) {
23
+ throw new Error(`unsafe consumer path (not a regular file): ${path}`);
24
+ }
25
+ return absolute;
26
+ }
27
+
28
+ function separator() {
29
+ return process.platform === 'win32' ? '\\' : '/';
30
+ }
@@ -13,6 +13,8 @@ import { writeAtomic } from './atomicWrite.mjs';
13
13
  export const CONSUMER_MANIFEST_NAME = 'agent-workflow-kit.json';
14
14
  export const PACKAGE_MANIFEST_NAME = 'agent-workflow-kit.package.json';
15
15
  export const CONSUMER_INSTALL_ROLE = 'consumer';
16
+ export const KIT_ORIGIN = 'kit';
17
+ export const CONSUMER_ORIGIN = 'consumer';
16
18
 
17
19
  /**
18
20
  * Parse a JSON manifest, or null if the file does not exist. A corrupt file
@@ -66,3 +68,19 @@ export function indexByPath(manifest, key) {
66
68
  for (const entry of (manifest?.[key] ?? [])) idx.set(entry.path, entry);
67
69
  return idx;
68
70
  }
71
+
72
+ /** Return a manifest with one tracked entry moved to the requested ownership state. */
73
+ export function withOrigin(manifest, path, origin) {
74
+ if (![KIT_ORIGIN, CONSUMER_ORIGIN].includes(origin)) {
75
+ throw new Error(`invalid manifest origin: ${origin}`);
76
+ }
77
+ const current = (manifest?.installed ?? []).find((entry) => entry.path === path);
78
+ if (!current) throw new Error(`unknown tracked path: ${path}`);
79
+ if ((current.origin ?? KIT_ORIGIN) === origin) {
80
+ throw new Error(`${path} is already ${origin}-owned`);
81
+ }
82
+ return {
83
+ ...manifest,
84
+ installed: manifest.installed.map((entry) => entry.path === path ? { ...entry, origin } : entry),
85
+ };
86
+ }
@@ -0,0 +1,88 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { lineDiff } from './atomicWrite.mjs';
4
+ import { validateConsumerFile } from './consumerPath.mjs';
5
+ import { sha256 } from './hash.mjs';
6
+ import {
7
+ CONSUMER_MANIFEST_NAME, CONSUMER_ORIGIN, PACKAGE_MANIFEST_NAME, indexByPath, readManifest,
8
+ } from './manifest.mjs';
9
+
10
+ /** Describe on demand how current package entries differ from consumer-owned paths. */
11
+ export async function ownedDiff({ kitRoot, consumerRoot }) {
12
+ const consumer = await readManifest(join(consumerRoot, CONSUMER_MANIFEST_NAME));
13
+ const pkg = await readManifest(join(kitRoot, PACKAGE_MANIFEST_NAME));
14
+ const packageByPath = indexByPath(pkg, 'files');
15
+ const results = [];
16
+
17
+ for (const installed of consumer.installed.filter(({ origin }) => origin === CONSUMER_ORIGIN)) {
18
+ const current = packageByPath.get(installed.path);
19
+ let localPath;
20
+ try {
21
+ localPath = await validateConsumerFile(consumerRoot, installed.path);
22
+ } catch (error) {
23
+ if (error.message === `unsafe consumer path (not a regular file): ${installed.path}` &&
24
+ await isMissing(join(consumerRoot, installed.path))) {
25
+ results.push({ path: installed.path, state: 'missing-locally' });
26
+ continue;
27
+ }
28
+ if (error.message.startsWith('unsafe consumer path')) {
29
+ results.push({ path: installed.path, state: 'unsafe-path' });
30
+ continue;
31
+ }
32
+ throw error;
33
+ }
34
+ if (!current) {
35
+ results.push({ path: installed.path, state: 'removed-upstream' });
36
+ continue;
37
+ }
38
+ const local = await readFile(localPath);
39
+ let packagePath;
40
+ try {
41
+ packagePath = await validateConsumerFile(kitRoot, current.path);
42
+ } catch (error) {
43
+ if (error.message.startsWith('unsafe consumer path')) {
44
+ results.push({ path: installed.path, state: 'unsafe-path' });
45
+ continue;
46
+ }
47
+ throw error;
48
+ }
49
+ const upstream = await readFile(packagePath);
50
+ if (local.equals(upstream)) {
51
+ results.push({ path: installed.path, state: 'identical' });
52
+ continue;
53
+ }
54
+ if (isBinary(local) || isBinary(upstream)) {
55
+ results.push({
56
+ path: installed.path,
57
+ state: 'changed-upstream',
58
+ binary: true,
59
+ local: { size: local.length, sha256: sha256(local) },
60
+ upstream: { size: upstream.length, sha256: sha256(upstream) },
61
+ });
62
+ continue;
63
+ }
64
+ results.push({
65
+ path: installed.path,
66
+ state: 'changed-upstream',
67
+ binary: false,
68
+ diff: lineDiff(local.toString('utf8'), upstream.toString('utf8')),
69
+ });
70
+ }
71
+ return results;
72
+ }
73
+
74
+ async function isMissing(path) {
75
+ try {
76
+ await lstat(path);
77
+ return false;
78
+ } catch (error) {
79
+ if (error.code === 'ENOENT') return true;
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ function isBinary(bytes) {
85
+ if (bytes.includes(0)) return true;
86
+ const text = bytes.toString('utf8');
87
+ return !Buffer.from(text, 'utf8').equals(bytes);
88
+ }