@ikon85/agent-workflow-kit 0.42.1 → 0.43.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/README.md CHANGED
@@ -469,6 +469,25 @@ the old way. Decision record:
469
469
 
470
470
  ## Release notes
471
471
 
472
+ ### 0.43.0
473
+
474
+ - removed: `docs/agents/domain.md`
475
+ - removed: `docs/agents/issue-tracker.md`
476
+ - removed: `docs/agents/skills/local-ci.md`
477
+ - removed: `docs/agents/skills/orchestrate-wave.md`
478
+ - removed: `docs/agents/skills/spec-self-critique.md`
479
+ - removed: `docs/agents/triage-labels.md`
480
+ - removed: `docs/conventions/spec-completeness.md`
481
+ - changed: `package.json`
482
+ - changed: `scripts/build-kit.test.mjs`
483
+ - changed: `scripts/codex-exec-scenarios/fake-codex.mjs`
484
+ - changed: `scripts/codex-exec.test.mjs`
485
+ - changed: `scripts/release-delta-guard.mjs`
486
+ - changed: `scripts/release-delta-guard.test.mjs`
487
+ - changed: `scripts/test_worktree_wrapup_contract.py`
488
+ - changed: `scripts/worktree-lifecycle/README.md`
489
+ - changed: `scripts/worktree-lifecycle/core.py`
490
+
472
491
  ### 0.42.1
473
492
 
474
493
  - changed: `docs/agents/skills/orchestrate-wave.md`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.42.1",
2
+ "kitVersion": "0.43.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -2699,7 +2699,7 @@
2699
2699
  "path": "scripts/release-delta-guard.mjs",
2700
2700
  "kind": "script",
2701
2701
  "installRole": "maintainer",
2702
- "sha256": "566d7bae5526af2f8863d7a06dfa3016c9383b241b616cd43757eb7b46555895",
2702
+ "sha256": "41d9fd9c86324f1428a3de04f2bfcb630e868525d88a8864d7bdfc2954e00414",
2703
2703
  "mode": 420,
2704
2704
  "origin": "kit"
2705
2705
  },
@@ -2811,7 +2811,7 @@
2811
2811
  "path": "scripts/worktree-lifecycle/core.py",
2812
2812
  "kind": "script",
2813
2813
  "installRole": "consumer",
2814
- "sha256": "50c60de1e0238fa1f5cd627f67e57ca4f378275136691411487f36d7fda580dd",
2814
+ "sha256": "2c89528a7beb629eb8b61ced26dd68cd8cd19fe2d37a4934badf74cede51e832",
2815
2815
  "mode": 420,
2816
2816
  "origin": "kit"
2817
2817
  },
@@ -2843,7 +2843,7 @@
2843
2843
  "path": "scripts/worktree-lifecycle/README.md",
2844
2844
  "kind": "doc",
2845
2845
  "installRole": "consumer",
2846
- "sha256": "0dbba4a87d41171f3fce22a4842eecd2a989ea6a5ddf44832d7b2aed9feb4226",
2846
+ "sha256": "a5e89b57eeb817075214af30e6504f4077434b9f90fb08a19aea028144073448",
2847
2847
  "mode": 420,
2848
2848
  "origin": "kit"
2849
2849
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,16 @@
24
24
  "assets/",
25
25
  "scripts/",
26
26
  "docs/",
27
+ "!docs/agents/issue-tracker.md",
28
+ "!docs/agents/triage-labels.md",
29
+ "!docs/agents/domain.md",
30
+ "!docs/agents/skills/spec-self-critique.md",
31
+ "!docs/agents/skills/orchestrate-wave.md",
32
+ "!docs/agents/skills/local-ci.md",
33
+ "!docs/agents/skills/git-worktree-recover.md",
34
+ "!docs/agents/skills/audit-skills.md",
35
+ "!docs/agents/skills/security-audit.md",
36
+ "!docs/conventions/spec-completeness.md",
27
37
  "src/",
28
38
  "agent-workflow-kit.package.json",
29
39
  "LICENSE",
@@ -8,7 +8,7 @@ import { createHash } from 'node:crypto';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { buildKit } from './build-kit.mjs';
10
10
  import { init } from '../src/commands/init.mjs';
11
- import { HELPER_FILES } from '../src/lib/bundle.mjs';
11
+ import { HELPER_FILES, STUB_TARGETS } from '../src/lib/bundle.mjs';
12
12
  import { CONSUMER_MANIFEST_NAME, readManifest } from '../src/lib/manifest.mjs';
13
13
 
14
14
  const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -155,6 +155,9 @@ test('npm pack keeps product files but excludes runtime residue', async () => {
155
155
  for (const path of PUBLIC_CENSUS_UNIT) {
156
156
  assert.ok(files.includes(path), `pack missing ${path}`);
157
157
  }
158
+ for (const path of STUB_TARGETS) {
159
+ assert.ok(!files.includes(path), `pack must exclude init stub target ${path}`);
160
+ }
158
161
  const packedByPath = new Map(JSON.parse(output)[0].files.map((file) => [file.path, file]));
159
162
  assert.deepEqual(
160
163
  WAVE_152_HELPERS.map(({ path, mode }) => ({ path, mode: packedByPath.get(path)?.mode })),
@@ -52,6 +52,16 @@ const verdict = () => emit({
52
52
  item: { id: 'fake-item', type: 'agent_message', text: 'fake verdict' },
53
53
  });
54
54
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
55
+ const waitForSignal = () => new Promise((resolve) => {
56
+ const keepAlive = setInterval(() => {}, 2_147_483_647);
57
+ const done = () => {
58
+ clearInterval(keepAlive);
59
+ resolve();
60
+ };
61
+ process.once('SIGTERM', done);
62
+ process.once('SIGINT', done);
63
+ process.once('SIGHUP', done);
64
+ });
55
65
 
56
66
  switch (scenario) {
57
67
  case 'silent':
@@ -129,7 +139,10 @@ switch (scenario) {
129
139
  if (process.env.FAKE_CODEX_CHILD_PID) {
130
140
  writeFileSync(process.env.FAKE_CODEX_CHILD_PID, String(child.pid));
131
141
  }
132
- await sleep(pause);
142
+ if (process.env.FAKE_CODEX_GROUP_HANG_READY) {
143
+ writeFileSync(process.env.FAKE_CODEX_GROUP_HANG_READY, 'ready\n');
144
+ }
145
+ await waitForSignal();
133
146
  break;
134
147
  }
135
148
  case 'orphan-group': {
@@ -369,15 +369,17 @@ test('automatic stale cleanup is bounded and never removes retained or active st
369
369
 
370
370
  test('invalid stale cleanup limits fail before launch and preserve an active run', async () => {
371
371
  const fx = fixture();
372
+ const ready = join(fx.dir, 'group-hang.ready');
372
373
  const running = spawn(helper, launchArgs('build'), {
373
374
  cwd: root, encoding: 'utf8', env: {
374
375
  ...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot,
375
- FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_PAUSE_MS: '5000',
376
+ FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_GROUP_HANG_READY: ready,
376
377
  FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
377
378
  },
378
379
  });
379
380
  running.stdout.on('data', () => {});
380
381
  try {
382
+ assert.ok(await waitFor(() => exists(ready)));
381
383
  const stateName = await waitFor(() => exists(fx.stateRoot) && readdirSync(fx.stateRoot)
382
384
  .find((name) => readJson(join(fx.stateRoot, name, 'runtime.json'))?.phase === 'running'));
383
385
  assert.ok(stateName);
@@ -452,7 +454,6 @@ test('stale cleanup cannot delete a run whose resume acquired the shared lease',
452
454
  ...process.env,
453
455
  CODEX_EXEC_STATE_ROOT: fx.stateRoot,
454
456
  FAKE_CODEX_SCENARIO: 'group-hang',
455
- FAKE_CODEX_PAUSE_MS: '5000',
456
457
  FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
457
458
  },
458
459
  });
@@ -577,7 +578,7 @@ test('timeout kills only the owned group and leaves a decoy sibling alive', () =
577
578
  try {
578
579
  const args = launchArgs().map((value, index, all) => all[index - 1] === '--timeout' ? '0.35' : value);
579
580
  const result = invoke(fx, args, {
580
- FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_PAUSE_MS: '2000', FAKE_CODEX_CHILD_PID: childPid,
581
+ FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_CHILD_PID: childPid,
581
582
  });
582
583
  assert.equal(result.output.status, 'TIMEOUT');
583
584
  assert.equal(exists(result.output.stateDir), true);
@@ -598,7 +599,7 @@ test('a concurrent resume and finalize cannot steal an owned round lease', async
598
599
  ], {
599
600
  cwd: root, encoding: 'utf8', env: {
600
601
  ...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot,
601
- FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_PAUSE_MS: '5000',
602
+ FAKE_CODEX_SCENARIO: 'group-hang',
602
603
  FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
603
604
  },
604
605
  });
@@ -655,7 +656,6 @@ test('a runtime publication failure reaps the owned child before releasing its l
655
656
  CODEX_EXEC_TEST_CHILD_PID_FILE: childPid,
656
657
  CODEX_EXEC_TEST_RUNTIME_WRITE_FAIL: '1',
657
658
  FAKE_CODEX_SCENARIO: 'group-hang',
658
- FAKE_CODEX_PAUSE_MS: '5000',
659
659
  });
660
660
  assert.notEqual(result.status, 0);
661
661
  assert.equal(result.output.error, 'ROUND_SUPERVISOR_FAILED');
@@ -795,7 +795,7 @@ test('abort cancels an active run, deletes its state, and spares a decoy', async
795
795
  const running = spawn(helper, launchArgs('build'), {
796
796
  cwd: root, encoding: 'utf8', env: {
797
797
  ...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot, FAKE_CODEX_SCENARIO: 'group-hang',
798
- FAKE_CODEX_PAUSE_MS: '5000', FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
798
+ FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
799
799
  },
800
800
  });
801
801
  let stdout = '';
@@ -823,7 +823,7 @@ test('handle-failure selects one of two simultaneous live runs and leaves the ot
823
823
  const start = () => spawn(helper, launchArgs('build'), {
824
824
  cwd: root, encoding: 'utf8', env: {
825
825
  ...process.env, CODEX_EXEC_STATE_ROOT: fx.stateRoot,
826
- FAKE_CODEX_SCENARIO: 'group-hang', FAKE_CODEX_PAUSE_MS: '5000',
826
+ FAKE_CODEX_SCENARIO: 'group-hang',
827
827
  FAKE_CODEX_LAUNCH_LOG: fx.launchLog,
828
828
  },
829
829
  });
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { STUB_TARGETS } from '../src/lib/bundle.mjs';
7
8
  import { sha256 } from '../src/lib/hash.mjs';
8
9
  import { buildKit } from './build-kit.mjs';
9
10
 
@@ -30,9 +31,10 @@ function mergeDeltas(...deltas) {
30
31
  ]));
31
32
  }
32
33
 
33
- export function recommendBump(delta) {
34
- if (delta.removed.length) return 'major';
35
- if (delta.added.length) return 'minor';
34
+ export function recommendBump(delta, { minorRemovals = [] } = {}) {
35
+ const minorRemovalSet = new Set(minorRemovals);
36
+ if (delta.removed.some((path) => !minorRemovalSet.has(path))) return 'major';
37
+ if (delta.added.length || delta.removed.length) return 'minor';
36
38
  return delta.changed.length ? 'patch' : null;
37
39
  }
38
40
 
@@ -79,7 +81,13 @@ export function assessRelease(input) {
79
81
  + `tag and publish it before preparing ${input.currentVersion}`,
80
82
  );
81
83
  }
82
- const recommendedBump = recommendBump(delta);
84
+ // Project-layer stubs are seed inputs, never installed Consumer files.
85
+ // Removing an accidentally packed copy is additive hygiene, unless the same
86
+ // path also disappeared from the actual Consumer bundle.
87
+ const seedOnlyPackageRemovals = packageDelta.removed.filter(
88
+ (path) => STUB_TARGETS.includes(path) && !bundleDelta.removed.includes(path),
89
+ );
90
+ const recommendedBump = recommendBump(delta, { minorRemovals: seedOnlyPackageRemovals });
83
91
  const actual = bumpKind(input.baseVersion, input.currentVersion);
84
92
  const rank = { patch: 1, minor: 2, major: 3 };
85
93
  if (hasDelta(delta) && input.currentVersion !== input.baseVersion && !actual) {
@@ -58,6 +58,29 @@ test('an npm runtime change outside the Consumer manifest still requires a relea
58
58
  assert.match(result.errors.join('\n'), /version remains 1\.2\.3/);
59
59
  });
60
60
 
61
+ test('removing accidentally packed seed-only project stubs is a minor change', () => {
62
+ const consumer = { kitVersion: '1.3.0', files: [file('skill.md', 'same')] };
63
+ const result = assessRelease({
64
+ baseVersion: '1.2.3',
65
+ currentVersion: '1.3.0',
66
+ baseManifest: { ...consumer, kitVersion: '1.2.3' },
67
+ builtManifest: consumer,
68
+ checkedManifest: consumer,
69
+ payloadManifest: consumer,
70
+ basePackagePayload: {
71
+ files: [
72
+ file('src/cli.mjs', 'same'),
73
+ file('docs/agents/issue-tracker.md', 'filled-project-layer'),
74
+ ],
75
+ },
76
+ currentPackagePayload: { files: [file('src/cli.mjs', 'same')] },
77
+ });
78
+
79
+ assert.equal(result.ok, true);
80
+ assert.deepEqual(result.delta.removed, ['docs/agents/issue-tracker.md']);
81
+ assert.equal(result.recommendedBump, 'minor');
82
+ });
83
+
61
84
  test('dead checked-manifest entries are rejected', () => {
62
85
  const result = assessRelease({
63
86
  baseVersion: '1.2.3', currentVersion: '1.3.0',
@@ -51,7 +51,7 @@ def create_merged_worktree(
51
51
  command(["git", "config", "user.name", "Test"], main)
52
52
  command(["git", "config", "user.email", "test@example.invalid"], main)
53
53
  (main / ".gitignore").write_text(
54
- ".worktrees/\ndist-kit/\n__pycache__/\n.claude/logs/\nconsumer/\n",
54
+ ".worktrees/\ndist-kit/\n__pycache__/\n.claude/logs/\nconsumer/\nnode_modules/\n",
55
55
  encoding="utf-8",
56
56
  )
57
57
  profile = main / "docs/agents/workflow-capabilities.json"
@@ -773,6 +773,35 @@ class WorktreeCleanupContract(unittest.TestCase):
773
773
  self.assertFalse(worktree.exists())
774
774
  self.assertTrue(second["merged"])
775
775
 
776
+ def test_pnpm_symlink_farm_reaches_guarded_land_idempotently(self):
777
+ wrapup = load_wrapup()
778
+ with tempfile.TemporaryDirectory() as tmp:
779
+ main, worktree = create_merged_worktree(
780
+ Path(tmp), scratch_patterns=["node_modules/**"]
781
+ )
782
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
783
+ wrapup.freeze_landing_artifact_evidence(
784
+ str(worktree), str(main), push_succeeded=True
785
+ )
786
+ package = (
787
+ worktree
788
+ / "node_modules/.pnpm/example@1.0.0/node_modules/example"
789
+ )
790
+ package.mkdir(parents=True)
791
+ (package / "index.js").write_text(
792
+ "export default 1;\n", encoding="utf-8"
793
+ )
794
+ (worktree / "node_modules/example").symlink_to(
795
+ ".pnpm/example@1.0.0/node_modules/example"
796
+ )
797
+
798
+ first = run_land(wrapup, main, land_args())
799
+ second = run_land(wrapup, main, land_args())
800
+
801
+ self.assertEqual(first["worktree_removed"], str(worktree))
802
+ self.assertFalse(worktree.exists())
803
+ self.assertTrue(second["merged"])
804
+
776
805
  def test_append_after_profile_log_assessment_is_preserved_and_stops(self):
777
806
  wrapup = load_wrapup()
778
807
  with tempfile.TemporaryDirectory() as tmp:
@@ -934,6 +963,66 @@ class WorktreeCleanupContract(unittest.TestCase):
934
963
  self.assertTrue(generated.exists())
935
964
  self.assertTrue(late.exists())
936
965
 
966
+ def test_late_profile_scratch_symlink_stops_before_assessed_links_are_removed(self):
967
+ wrapup = load_wrapup()
968
+ with tempfile.TemporaryDirectory() as tmp:
969
+ main, worktree = create_merged_worktree(
970
+ Path(tmp), scratch_patterns=["node_modules/**"]
971
+ )
972
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
973
+ packages = worktree / "node_modules/.pnpm"
974
+ first_target = packages / "first@1.0.0/node_modules/first"
975
+ first_target.mkdir(parents=True)
976
+ first = worktree / "node_modules/first"
977
+ first.symlink_to(".pnpm/first@1.0.0/node_modules/first")
978
+ assessment = wrapup.ensure_worktree_removable(
979
+ str(worktree), str(main)
980
+ )
981
+ late_target = packages / "late@1.0.0/node_modules/late"
982
+ late_target.mkdir(parents=True)
983
+ late = worktree / "node_modules/late"
984
+ late.symlink_to(".pnpm/late@1.0.0/node_modules/late")
985
+
986
+ with self.assertRaises(wrapup.Stop) as stopped:
987
+ wrapup.remove_verified_worktree_scratch(
988
+ str(worktree), str(main), assessment
989
+ )
990
+
991
+ self.assertIn(
992
+ "inventory no longer matches preview", stopped.exception.reason
993
+ )
994
+ self.assertTrue(first.is_symlink())
995
+ self.assertTrue(late.is_symlink())
996
+
997
+ def test_changed_internal_symlink_target_identity_stops_cleanup(self):
998
+ wrapup = load_wrapup()
999
+ with tempfile.TemporaryDirectory() as tmp:
1000
+ main, worktree = create_merged_worktree(
1001
+ Path(tmp), scratch_patterns=["PLAN.md"]
1002
+ )
1003
+ wrapup.landing_start_artifact_inventory(str(worktree), str(main))
1004
+ link = worktree / "PLAN.md"
1005
+ link.symlink_to("change.txt")
1006
+ assessment = wrapup.ensure_worktree_removable(
1007
+ str(worktree), str(main)
1008
+ )
1009
+ tracked_target = worktree / "change.txt"
1010
+ original = tracked_target.read_text(encoding="utf-8")
1011
+ replacement = worktree / "replacement.tmp"
1012
+ replacement.write_text(original, encoding="utf-8")
1013
+ os.replace(replacement, tracked_target)
1014
+
1015
+ with self.assertRaises(wrapup.Stop) as stopped:
1016
+ wrapup.remove_verified_worktree_scratch(
1017
+ str(worktree), str(main), assessment
1018
+ )
1019
+
1020
+ self.assertIn(
1021
+ "inventory no longer matches preview", stopped.exception.reason
1022
+ )
1023
+ self.assertTrue(link.is_symlink())
1024
+ self.assertEqual(tracked_target.read_text(encoding="utf-8"), original)
1025
+
937
1026
  def test_same_path_generated_replacement_is_preserved_by_frozen_evidence(self):
938
1027
  wrapup = load_wrapup()
939
1028
  with tempfile.TemporaryDirectory() as tmp:
@@ -86,9 +86,14 @@ scratch is named in the report but does not block removal. The assessment reads
86
86
  processes or removing the worktree.
87
87
 
88
88
  Explicit removal re-collects facts immediately before mutation, requires the
89
- same removable inventory, deletes only the exact contained regular scratch
90
- files from that inventory, and uses ordinary `git worktree remove`. It never
91
- bypasses Git's final concurrent-change check with force removal.
89
+ same removable inventory, deletes only the exact contained scratch entries
90
+ from that inventory, and uses ordinary `git worktree remove`. Regular-file
91
+ identity stays byte-bound. A profile-authorized symlink is eligible only when
92
+ its relative target resolves to an existing entry inside the assessed
93
+ worktree; cleanup freezes both the link and resolved target identities, then
94
+ unlinks the link without following it. Absolute, escaping, dangling, changed,
95
+ unmatched, or late symlinks stop cleanup. It never bypasses Git's final
96
+ concurrent-change check with force removal.
92
97
 
93
98
  The generic setup route atomically records its ignored and complete
94
99
  untracked-file inventories in the linked worktree's Git metadata after its
@@ -129,9 +134,10 @@ The landing adapter may carry exact scratch evidence only for current ignored
129
134
  files that match the consumer-owned
130
135
  `wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
131
136
  creation baseline. Missing, changed, or incoherent provenance stops landing
132
- cleanup. Initial/profile-matched files, unmatched files, symlinks, and writes
133
- after the landing evidence snapshot remain cleanup stops; deletion still uses
134
- the same descriptor-bound regular-file primitive and a second inventory check.
137
+ cleanup. Initial/profile-matched files, unmatched files, landing-generated
138
+ symlinks, and writes after the landing evidence snapshot remain cleanup stops;
139
+ generator evidence and regular-file removal stay descriptor-bound and require
140
+ a second inventory check.
135
141
  Mutable session logs belong in explicit `scratchPatterns`, not in the landing
136
142
  generator allowlist: their identity is frozen at final cleanup assessment, so
137
143
  normal logging before teardown remains live while a later append or replacement
@@ -1220,17 +1220,28 @@ def remove_authorized_scratch(
1220
1220
  raise LifecycleError(
1221
1221
  f"landing-generated scratch evidence is missing: {relative}"
1222
1222
  )
1223
+ if generated and expected.get("kind") == "symlink":
1224
+ raise LifecycleError(
1225
+ f"landing-generated scratch path is not a regular file: {relative}"
1226
+ )
1223
1227
  if profile_authorized and expected is None:
1224
1228
  raise LifecycleError(f"profile scratch evidence is missing: {relative}")
1225
1229
  if not profile_authorized and expected is None:
1226
1230
  raise LifecycleError(f"verified scratch evidence is missing: {relative}")
1227
1231
  for relative in scratch_files:
1228
1232
  expected = evidence_by_path[relative]
1229
- remove_contained_regular(
1230
- root_descriptor,
1231
- relative,
1232
- expected_identity=expected,
1233
- )
1233
+ if expected.get("kind") == "symlink":
1234
+ remove_contained_untracked(
1235
+ root_descriptor,
1236
+ expected,
1237
+ require_contained_symlink_target=True,
1238
+ )
1239
+ else:
1240
+ remove_contained_regular(
1241
+ root_descriptor,
1242
+ relative,
1243
+ expected_identity=expected,
1244
+ )
1234
1245
 
1235
1246
 
1236
1247
  def bind_cleanup_scratch_evidence(
@@ -1292,7 +1303,7 @@ def bind_cleanup_scratch_evidence(
1292
1303
  assessment.root_inode,
1293
1304
  ) as descriptor:
1294
1305
  for relative in profile_only:
1295
- evidence_by_path[relative] = contained_regular_identity(
1306
+ evidence_by_path[relative] = contained_profile_scratch_identity(
1296
1307
  descriptor, relative
1297
1308
  )
1298
1309
  return replace(
@@ -1366,6 +1377,8 @@ def contained_regular_identity(root_descriptor: int, relative: str) -> dict[str,
1366
1377
  def contained_untracked_identity(
1367
1378
  root_descriptor: int,
1368
1379
  relative: str,
1380
+ *,
1381
+ require_contained_symlink_target: bool = False,
1369
1382
  ) -> dict[str, Any]:
1370
1383
  """Read exact regular/symlink identity without following any symlink."""
1371
1384
  path = PurePosixPath(relative)
@@ -1393,10 +1406,26 @@ def contained_untracked_identity(
1393
1406
  "size": metadata.st_size,
1394
1407
  }
1395
1408
  if stat.S_ISLNK(metadata.st_mode):
1396
- target = os.readlink(path.name, dir_fd=current).encode(
1409
+ target_text = os.readlink(path.name, dir_fd=current)
1410
+ target = target_text.encode(
1397
1411
  "utf-8", errors="surrogateescape"
1398
1412
  )
1399
- return {**common, "kind": "symlink", "sha256": sha256(target).hexdigest()}
1413
+ identity = {
1414
+ **common,
1415
+ "kind": "symlink",
1416
+ "sha256": sha256(target).hexdigest(),
1417
+ }
1418
+ if require_contained_symlink_target:
1419
+ target_metadata = contained_symlink_target_identity(
1420
+ root_descriptor,
1421
+ relative,
1422
+ target_text,
1423
+ )
1424
+ identity.update({
1425
+ "targetDevice": target_metadata.st_dev,
1426
+ "targetInode": target_metadata.st_ino,
1427
+ })
1428
+ return identity
1400
1429
  if not stat.S_ISREG(metadata.st_mode):
1401
1430
  raise LifecycleError(f"unsupported recovery path type: {relative}")
1402
1431
  file_descriptor = os.open(
@@ -1427,15 +1456,126 @@ def contained_untracked_identity(
1427
1456
  os.close(descriptor)
1428
1457
 
1429
1458
 
1459
+ def contained_profile_scratch_identity(
1460
+ root_descriptor: int,
1461
+ relative: str,
1462
+ ) -> dict[str, Any]:
1463
+ """Freeze regular scratch unchanged, or one contained symlink and its target."""
1464
+ identity = contained_untracked_identity(
1465
+ root_descriptor,
1466
+ relative,
1467
+ require_contained_symlink_target=True,
1468
+ )
1469
+ if identity["kind"] == "regular":
1470
+ return {
1471
+ key: value
1472
+ for key, value in identity.items()
1473
+ if key != "kind"
1474
+ }
1475
+ return identity
1476
+
1477
+
1478
+ def _relative_target_parts(
1479
+ base_parts: list[str],
1480
+ target: str,
1481
+ relative: str,
1482
+ ) -> list[str]:
1483
+ target_path = PurePosixPath(target)
1484
+ if target_path.is_absolute():
1485
+ raise LifecycleError(f"scratch symlink target is absolute: {relative}")
1486
+ resolved = list(base_parts)
1487
+ for component in target_path.parts:
1488
+ if component in {"", "."}:
1489
+ continue
1490
+ if component == "..":
1491
+ if not resolved:
1492
+ raise LifecycleError(
1493
+ f"scratch symlink target escapes worktree: {relative}"
1494
+ )
1495
+ resolved.pop()
1496
+ continue
1497
+ resolved.append(component)
1498
+ return resolved
1499
+
1500
+
1501
+ def contained_symlink_target_identity(
1502
+ root_descriptor: int,
1503
+ relative: str,
1504
+ target: str,
1505
+ ) -> os.stat_result:
1506
+ """Resolve one symlink target beneath the open root, rejecting every escape."""
1507
+ link = PurePosixPath(relative)
1508
+ pending = _relative_target_parts(list(link.parts[:-1]), target, relative)
1509
+ resolved: list[str] = []
1510
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1511
+ no_follow = getattr(os, "O_NOFOLLOW", 0)
1512
+ symlink_count = 0
1513
+ try:
1514
+ if not pending:
1515
+ return os.fstat(root_descriptor)
1516
+ while pending:
1517
+ component = pending.pop(0)
1518
+ descriptors = []
1519
+ try:
1520
+ parent = root_descriptor
1521
+ for ancestor in resolved:
1522
+ parent = os.open(
1523
+ ancestor,
1524
+ directory_flags | no_follow,
1525
+ dir_fd=parent,
1526
+ )
1527
+ descriptors.append(parent)
1528
+ metadata = os.stat(
1529
+ component,
1530
+ dir_fd=parent,
1531
+ follow_symlinks=False,
1532
+ )
1533
+ if stat.S_ISLNK(metadata.st_mode):
1534
+ symlink_count += 1
1535
+ if symlink_count > 40:
1536
+ raise OSError("too many symbolic links")
1537
+ nested_target = os.readlink(component, dir_fd=parent)
1538
+ pending = _relative_target_parts(
1539
+ resolved,
1540
+ nested_target,
1541
+ relative,
1542
+ ) + pending
1543
+ resolved = []
1544
+ if not pending:
1545
+ return os.fstat(root_descriptor)
1546
+ continue
1547
+ if pending and not stat.S_ISDIR(metadata.st_mode):
1548
+ raise OSError("symlink target parent is not a directory")
1549
+ resolved.append(component)
1550
+ if not pending:
1551
+ return metadata
1552
+ finally:
1553
+ for descriptor in reversed(descriptors):
1554
+ os.close(descriptor)
1555
+ except LifecycleError:
1556
+ raise
1557
+ except OSError as error:
1558
+ raise LifecycleError(
1559
+ f"scratch symlink target is dangling or changed: {relative}"
1560
+ ) from error
1561
+ raise LifecycleError(f"scratch symlink target is dangling or changed: {relative}")
1562
+
1563
+
1430
1564
  def remove_contained_untracked(
1431
1565
  root_descriptor: int,
1432
1566
  expected_identity: dict[str, Any],
1567
+ *,
1568
+ require_contained_symlink_target: bool = False,
1433
1569
  ) -> None:
1434
1570
  """Unlink one frozen regular file or symlink if its identity still matches."""
1435
1571
  relative = expected_identity.get("path")
1436
1572
  if not isinstance(relative, str):
1437
1573
  raise LifecycleError("recovery path evidence is incoherent")
1438
- current = contained_untracked_identity(root_descriptor, relative)
1574
+ current = contained_untracked_identity(
1575
+ root_descriptor,
1576
+ relative,
1577
+ require_contained_symlink_target=require_contained_symlink_target,
1578
+ )
1439
1579
  if current != expected_identity:
1440
1580
  raise LifecycleError(f"recovery path identity changed: {relative}")
1441
1581
  path = PurePosixPath(relative)
@@ -1,7 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- # Domain docs
3
-
4
- This is a single-context repository. Before design work, read root `CONTEXT.md` when present and the relevant decisions under `docs/adr/`. Their absence is non-blocking: `grill-with-docs` creates them when the product language or a durable decision first needs recording.
5
-
6
- Use glossary terms consistently in issues, tests, and skill text. Surface conflicts with an existing ADR instead of silently replacing its decision.
7
-
@@ -1,23 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- # Issue tracker: GitHub
3
-
4
- Issues and PRDs for this repo live in `iKon85/agent-workflow-kit` as GitHub issues.
5
-
6
- ## Conventions
7
-
8
- - Use `gh` for issue reads and comments; infer the repository from the remote.
9
- - Write multi-line issue and PR bodies through a temporary file and `--body-file`.
10
- - Route board writes, status/wave/cluster field edits, dependencies, and sub-issue links through `scripts/board-sync.py`, not bare `gh project` commands.
11
- - Board state is authoritative for workflow status; GitHub issues remain the durable content source.
12
-
13
- ## Pickup claim
14
-
15
- Used by `/implement`, `/diagnose`, and `/orchestrate-wave` before the first
16
- edit. Board status `In Progress` is the human-facing signal; the claim below is
17
- the machine-readable one, and it is the only one that names the branch and
18
- worktree a colliding session would have to find.
19
-
20
- - **Check**: `gh issue view <n> --json assignees,comments --jq '{assignees: [.assignees[].login], claims: [.comments[].body | select(contains("<!-- agent-claim:"))]}'` — a foreign assignee or a marker this session did not plant is a foreign claim: stop, report the claimed branch/worktree, and leave it alone.
21
- - **Claim**: `gh issue edit <n> --add-assignee @me`, then `gh issue comment <n> --body '<!-- agent-claim: branch=<branch>; worktree=<absolute-path>; date=<YYYY-MM-DD> -->'`. Board status stays a `scripts/board-sync.py` write, never a bare `gh project` call.
22
- - **Release**: the slice PR (`closes #<n>` / `Part of #<anchor>`) supersedes the claim, so `/wrapup` needs no extra step. On abandon: `gh issue edit <n> --remove-assignee @me` plus a `claim released` comment.
23
-
@@ -1,90 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- <!-- agent-workflow-kit: project-extension/v1; skill=local-ci -->
3
- # Project layer — local-ci
4
-
5
- This repo is a Node + stdlib-Python repo with no database, no dev server and no
6
- typecheck step, so the two generic profiles map onto a short command set. Run the
7
- two commands below; do not infer others.
8
-
9
- Prerequisite once per clone (worktrees inherit it):
10
-
11
- ```sh
12
- git config core.hooksPath .githooks
13
- ```
14
-
15
- ## Fast static guards
16
-
17
- The skill/manifest lints, ~3s, no network, no DB. This is exactly what
18
- `.githooks/pre-commit` runs, so a normal commit already covers it:
19
-
20
- ```sh
21
- python3 -m unittest discover -s scripts -p 'test_skill_*.py' -q
22
- ```
23
-
24
- ## Full gate
25
-
26
- Mirrors the `test` job in `.github/workflows/ci.yml` step for step. Run it from
27
- the branch's worktree root before opening a PR:
28
-
29
- ```sh
30
- npm test
31
- npm run kit:staleness
32
- npm run release:guard -- --base "$(git merge-base origin/main HEAD)"
33
- npm pack --dry-run
34
- ```
35
-
36
- Notes on the individual steps:
37
-
38
- - `npm test` is `test:node` (`node --test`) plus `test:python`
39
- (`python3 -m unittest discover -s scripts -p 'test_*.py'`). Several negative-path
40
- tests print `[FAIL] …` lines by design; only the runner's own summary and exit
41
- code decide red or green.
42
- - CI runs `npm install --ignore-scripts` first. Locally that is only needed after
43
- a dependency change.
44
- - `release:guard` runs in CI on `pull_request` only, against
45
- `github.event.pull_request.base.sha`. `git merge-base origin/main HEAD` is the
46
- local equivalent; `git fetch origin main` first if `origin/main` is stale.
47
- - `npm pack --dry-run` catches packaging drift (a file added outside the
48
- published set) without publishing anything.
49
-
50
- ## Enforcement
51
-
52
- Since #220 the host **does** enforce: the `main protection` ruleset makes the CI
53
- job `test` a required status check on `main`, with no bypass actor. So the
54
- generic skill's "When the host CAN enforce" branch applies to this repo — the
55
- local gate is a pre-flight that shortens the feedback loop, not the only thing
56
- standing between a red branch and `main`.
57
-
58
- Layered, from cheapest to strongest:
59
-
60
- | Layer | Runs | Scope |
61
- | --- | --- | --- |
62
- | `pre-commit` | automatic, every commit | fast skill/manifest lints |
63
- | `pre-push` | automatic, every push | full `npm test` |
64
- | this full gate | explicit, before a PR | `npm test` + staleness + release guard + pack |
65
- | CI `test` | automatic, on the PR | the same set, machine-enforced at merge |
66
-
67
- Never bypass a hook with `--no-verify`.
68
-
69
- **Drift rule.** The full gate above and `.github/workflows/ci.yml` are two copies
70
- of one list. Change one, change the other in the same PR — otherwise the local
71
- gate goes green on a set the required check does not accept.
72
-
73
- ## Contention
74
-
75
- Not applicable here. This repo has no dev server and the suites are process-level
76
- (`node --test`, `unittest`), so the generic boot-contention false-red guidance has
77
- no target. A red is a real red until an isolated re-run of that one test proves
78
- otherwise.
79
-
80
- ## On a red
81
-
82
- 1. Read the failing assertion — the guards print the exact drift (`file:line`,
83
- the offending token, the missing manifest key).
84
- 2. Fix the source. A guard that legitimately cannot be satisfied yet gets a
85
- documented allowlist entry with a reason; never widen a guard silently.
86
- 3. Re-run the single failing suite, then the full gate for sign-off.
87
-
88
- `kit:staleness` red almost always means the generated kit artefacts lag the
89
- skill sources — run `npm run kit:build` and commit the result rather than
90
- editing the generated output by hand.
@@ -1,166 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- <!-- agent-workflow-kit: project-extension/v1; skill=orchestrate-wave -->
3
- # Project layer — orchestrate-wave
4
-
5
- Filled 2026-07-27 after Welle 18 (#289) ran the whole loop end-to-end — recon,
6
- build, integrate, verify, land, tag, publish — and proved the recipe below. The
7
- sections were deliberately empty until then; the generic fallback carried that
8
- run and cost it a `degraded` start plus a hand-derivation of every command here.
9
-
10
- This repo has **no database, no dev server, no typecheck step and no browser
11
- surface**, so several generic phases collapse to very little. Where that is the
12
- case the section says so rather than inventing a command.
13
-
14
- ## §Setup
15
-
16
- Nothing to start. No DB, no tunnel, no service, no dev server — Phase 0's
17
- "start what live-verify needs" is a no-op here.
18
-
19
- Two things that are *not* no-ops:
20
-
21
- - `git config core.hooksPath .githooks` once per clone (worktrees inherit it).
22
- - `npm install` in the wave worktree after fast-forwarding to `origin/main` —
23
- the lockfile is untracked, so dependencies do not travel with the branch.
24
-
25
- ## §Builder Commands
26
-
27
- Per-slice gate, run by the implementer before reporting back:
28
-
29
- ```sh
30
- npm test # test:node (node --test) + test:python (unittest)
31
- ```
32
-
33
- The fast pre-commit lints run automatically via `.githooks/pre-commit`
34
- (~3 s, skill/manifest lints). A slice is not "green" on the fast lints alone.
35
-
36
- ## §Builder Hard Rules
37
-
38
- - **Never `--no-verify`.** pre-commit and pre-push are the backstop, not an
39
- obstacle.
40
- - **English-first prose** in every published skill; the language census in the
41
- maintainer suite enforces it.
42
- - **No hardcoded board values** — labels, headings, field IDs and status names
43
- come from `docs/agents/board-sync.md`; the portability lint blocks literals.
44
- - **Dual-surface mirror in the same PR.** A changed `.claude/skills/<s>/SKILL.md`
45
- needs its `.agents/skills/<s>/SKILL.md` mirror via `codex-adapter-sync` in the
46
- same PR, never after merge.
47
- - **Shipped-file changes must preserve the manifest/reconcile contract.**
48
-
49
- ## §Integration Suites
50
-
51
- Both frameworks, every time — `npm test` runs both and a red in either is a red:
52
-
53
- ```sh
54
- npm test
55
- ```
56
-
57
- - `test:node` — `node --test` (665 tests as of 0.42.0)
58
- - `test:python` — `python3 -m unittest discover -s scripts -p 'test_*.py'`
59
- (522 tests as of 0.42.0)
60
-
61
- There is no typecheck step in this repo; do not look for one. Several
62
- negative-path tests print `[FAIL] …` lines **by design** — only the runner's own
63
- summary and exit code decide red or green.
64
-
65
- ## §Verify Recipe
66
-
67
- Central verify is the `/local-ci` full gate, run from the wave worktree root.
68
- It mirrors the CI `test` job step for step:
69
-
70
- ```sh
71
- git fetch origin main
72
- npm test
73
- npm run kit:staleness
74
- npm run release:guard -- --base "$(git merge-base origin/main HEAD)"
75
- npm pack --dry-run
76
- ```
77
-
78
- - **No branch-name-derived guard exists here**, so the generic skill's warning
79
- about an integrated verify branch blocking with no baseline does not apply.
80
- No skip or override is needed.
81
- - **No browser, no headless login, no screenshots, no console check, no DB
82
- compare, no design/brand check.** This repo ships text; "assert the AC as a
83
- user-visible outcome" means reading the artefact the slice produced (an ADR,
84
- a skill body, a script's output) and the tests that pin it.
85
- - **`release:guard` red with "shipped delta has no version bump" is not a
86
- failure of the wave** — it means the wave touched the shipped consumer set and
87
- the release metadata is missing. See §Landing.
88
- - **A flaky red is possible.** `scripts/codex-exec.test.mjs` contains
89
- timing-sensitive tests that fail under CPU contention (#345). Re-run the single
90
- test in isolation before treating it as a real red; a test that is green in
91
- isolation and red under load is contention, not the wave.
92
-
93
- ## §Headless Login
94
-
95
- Not applicable. No application, no browser surface, no authenticated session.
96
- Never invent a login recipe for this repo.
97
-
98
- ## §Landing
99
-
100
- **One PR for the whole wave**, base `main`. `main` is protected by the
101
- `main protection` ruleset: a PR is required, the CI job **`test`** (job name, not
102
- the workflow name `CI`) must be green with the branch up to date, and there are
103
- no bypass actors — `gh pr merge --admin` does not help.
104
-
105
- - `Closes #<n>` on its own line per leaf; `Part of #<anchor>` for the anchor,
106
- never `closes`.
107
- - Body via temp file + `--body-file`, and **run `gh` from the repo** (it resolves
108
- the target repo from the cwd).
109
-
110
- **Anchor reconcile, on PR create and on merge:**
111
-
112
- ```sh
113
- python3 scripts/board-sync.py anchor-sync <anchor#> --dry-run # review first
114
- python3 scripts/board-sync.py anchor-sync <anchor#>
115
- ```
116
-
117
- `anchor-sync` only works on a **promoted** anchor whose body carries a slice
118
- table between `<!-- slice-table:start -->` and `<!-- slice-table:end -->`. A
119
- stub-shaped anchor (a bullet list of slices, `wave-stub` label) makes it a no-op
120
- that exits 1 with "no slice table found — nothing to sync". That is a promotion
121
- gap, not a sync failure: install the table with the columns
122
- `| # | Status | Slice | Sub-Issue | Gate | closes/refs |`, drop the `wave-stub`
123
- label, then re-run. Do not hand-maintain the Status cells afterwards — they are
124
- regenerated from the board.
125
-
126
- **Completion, after merge:**
127
-
128
- ```sh
129
- python3 scripts/board-sync.py add --issue <anchor#> --status-role done
130
- gh issue close <anchor#> --reason completed --comment "…"
131
- python3 scripts/board-sync.py item-of --issue <anchor#> # re-read: status must be Done
132
- python3 scripts/board-sync.py parent-of <anchor#> # Program-PRD?
133
- python3 scripts/board-sync.py program-sync <prd#> # after the anchor is Done
134
- ```
135
-
136
- Closing the issue does **not** move the board field — set the field, then re-read
137
- both.
138
-
139
- **New issues** go through `python3 scripts/board-sync.py create --title … --body-file …`,
140
- never a bare `gh issue create`.
141
-
142
- ### Release lockstep
143
-
144
- There is no deploy and no migration, but there **is** a publication gate, and a
145
- wave that touches the shipped consumer set (anything in the kit manifest —
146
- including `docs/adr/*` and `docs/research/*`) drags it along.
147
-
148
- 1. `npm run release:prepare -- --base origin/main` reports the delta and a
149
- `recommendedBump` without writing anything.
150
- 2. **The confirmed Semver is the one human gate.** Ask, unless an explicit
151
- end-to-end mandate covers release preparation. That confirmed version then
152
- authorizes metadata, merge, tag *and* publish — do not ask again after merge.
153
- 3. `npm run release:prepare -- --version <x.y.z>`, commit the bump.
154
- 4. Merge the PR. Merging **integrates only**; it cannot publish.
155
- 5. Verify on canonical `main`: tip == merge commit, `package.json` version
156
- matches, no existing tag. Then `git tag -a v<x.y.z> <main-tip> -m "…"` and
157
- push. The annotated tag on the `main` tip is the sole publication intent.
158
- 6. **A red release run does not prove nothing was published.** Read
159
- `npm view @ikon85/agent-workflow-kit version` and `gh release view v<x.y.z>`
160
- directly, plus `npm run release:status` (cache-bypassing), before reacting.
161
- 7. **Recovery is `gh workflow run release.yml -f tag=v<x.y.z>` on the existing
162
- tag** — the reconciler is idempotent. **Never recover by bumping the version.**
163
-
164
- The release workflow runs the full suite *after* the tag is pushed, so a flaky
165
- test strands an already-published tag. That is the recovery path above, not a
166
- reason to re-tag.
@@ -1,18 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- <!-- agent-workflow-kit: project-extension/v1; skill=spec-self-critique -->
3
- # Project layer — spec-self-critique
4
-
5
- The generic checklist owns the checks; `/retro` appends project-specific incidents below these stable anchors.
6
-
7
- ## Point 1
8
- ## Point 2
9
- ## Point 3
10
- ## Point 4
11
- ## Point 5
12
- ## Point 6
13
- ## Point 7
14
- ## Point 8
15
- ## Point 9
16
- ## Point 10
17
- ## Point 11
18
- ## Point 12
@@ -1,10 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- # Triage labels
3
-
4
- | Canonical role | Label in this repository | Meaning |
5
- |---|---|---|
6
- | `needs-info` | `needs-info` | Waiting for information from the reporter |
7
- | `ready-for-agent` | `ready-for-agent` | Fully specified and safe for an AFK agent |
8
-
9
- The board Status field owns `Idea`, `Triaged`, `Spec`, `In Progress`, `Review`, and `Done`. Close rejected work instead of adding a separate `wontfix` workflow label.
10
-
@@ -1,23 +0,0 @@
1
- <!-- setup-workflow: state=filled -->
2
- # Convention — Spec completeness before implementation
3
-
4
- ## Self-Critique-Check
5
-
6
- **Trigger:** A spec states a concrete count, quantity, or `N of M` claim.
7
-
8
- **Check:** Is each number freshly derived from the source rather than estimated or recalled?
9
-
10
- **Korrektur:** Recount from code and replace the claim, or mark it explicitly as an implementation-time assumption.
11
-
12
- ## Vertical-slice completeness
13
-
14
- Every user-facing slice is an outcome tracer, carries at least one acceptance criterion, names its estimated blast radius, and identifies seam ownership. Every prep slice names the omitted half and the later slice that closes it. `to-issues` must re-derive this readiness rather than trusting a pre-cut table.
15
-
16
- ## Self-Critique-Check
17
-
18
- **Trigger:** A spec contains a wave/slice table or decomposes into multiple issues.
19
-
20
- **Check:** Are rows outcomes rather than layer names, and do enablers name the outcome wave that closes them?
21
-
22
- **Korrektur:** Reword or split layer-only rows and make every deferred half explicit.
23
-