@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,146 +0,0 @@
1
- /**
2
- * audit-refs.mjs — fail-closed publish guard: a shipped skill body must not
3
- * reference an executable (helper script / hook / git-hook) that the kit does
4
- * NOT ship. Such a reference is dangling-on-arrival in a consumer repo — the
5
- * skill instructs the agent to run something that was never installed.
6
- *
7
- * The gap this closes: neither the publish scrub (token-level) nor the
8
- * portability lint (board constants) nor build-kit.test (helpers *exist*) looks
9
- * at whether a skill body points at a script/hook absent from the bundle. A
10
- * skill edit that names `scripts/foo.py` shipped silently until a consumer hit
11
- * the dead path. This audit catches it at build time, at the source.
12
- *
13
- * Scope = executable references only. A `*.json` data file the kit instructs the
14
- * consumer to *seed* (e.g. max-lines-allowlist.json via /setup-workflow) is not
15
- * an executable and is intentionally out of scope. A pattern mention
16
- * (`*.guard.test.ts`) is not a concrete path and does not match.
17
- */
18
-
19
- // Concrete executable paths a skill body could tell the agent to run. Both the
20
- // `scripts/` and `.claude/hooks/` trees ship `.py` AND `.sh` helpers, so a body
21
- // that names a non-shipped shell script dangles exactly like a Python one.
22
- const EXEC_REF = /(?:scripts\/[\w.-]+\.(?:py|sh)|\.claude\/hooks\/[\w.-]+\.(?:py|sh)|\.githooks\/[\w.-]+)/g;
23
-
24
- /**
25
- * Refs that are intentionally NOT shipped at the referenced path. Two legit
26
- * classes: (a) a documented INSTALL TARGET — the skill ships the source under
27
- * its own `scripts/` dir and instructs the consumer to copy it to a hook path,
28
- * so the hook path never ships; (b) debt — knowingly not-yet-shippable, tracked
29
- * for follow-up. Each entry MUST carry a reason so the carve-out is visible,
30
- * never silent. Keep class (b) shrinking — debt is not a destination.
31
- */
32
- export const EXEMPT_REFS = {
33
- // (a) install target: git-guardrails ships the source at its own
34
- // scripts/block-dangerous-git.sh (skill-relative, shipped) and documents
35
- // copying it into the consumer's .claude/hooks/. The hook path is a destination.
36
- '.claude/hooks/block-dangerous-git.sh': 'install target; source ships at the skill\'s scripts/',
37
- // (a) install targets: setup-pre-commit ships the generic template at its own
38
- // scripts/pre-commit.template.sh and instructs the consumer to CREATE these
39
- // .githooks/ entries from it (wired via core.hooksPath). They never ship.
40
- '.githooks/pre-commit': 'install target; setup-pre-commit ships scripts/pre-commit.template.sh',
41
- '.githooks/pre-push': 'install target; optional heavy-gate hook the consumer creates',
42
- };
43
-
44
- /**
45
- * @param {(path: string) => (string|null)} readShipped returns the shipped
46
- * (scrubbed) body for a dist path, or null if it is not a scannable text file.
47
- * @param {Array<{path: string, kind: string}>} manifestFiles the shipped set.
48
- * @returns {Array<{file: string, ref: string}>} violations (empty = clean).
49
- */
50
- export function auditExecRefs(manifestFiles, readShipped) {
51
- const shipped = new Set(manifestFiles.map((f) => f.path));
52
- const violations = [];
53
- for (const f of manifestFiles) {
54
- if (f.kind !== 'skill' || !f.path.endsWith('.md')) continue;
55
- const body = readShipped(f.path);
56
- if (body == null) continue;
57
- // A `scripts/foo.sh` mention in a skill body may be skill-relative — the
58
- // helper bundles INSIDE the skill (`<skillDir>/scripts/foo.sh`), not at the
59
- // repo root. A ref is satisfied if EITHER the repo-root path OR the
60
- // skill-relative path ships. skillDir = the SKILL.md's directory.
61
- const skillDir = f.path.slice(0, f.path.lastIndexOf('/'));
62
- for (const ref of new Set(body.match(EXEC_REF) ?? [])) {
63
- if (shipped.has(ref) || shipped.has(`${skillDir}/${ref}`) || ref in EXEMPT_REFS) continue;
64
- violations.push({ file: f.path, ref });
65
- }
66
- }
67
- return violations;
68
- }
69
-
70
- // `from foo import …` / `import foo` — capture the top-level module name.
71
- const PY_IMPORT = /^[ \t]*(?:from[ \t]+(\w+)[ \t]+import|import[ \t]+(\w+))/gm;
72
-
73
- /**
74
- * Sibling guard: a shipped Python helper must not `import` a repo-local module
75
- * the kit does not ship (the bug that left drift-guard.py importing an unshipped
76
- * _hook_utils — invisible to the skill-body scan above). Scope = the repo's OWN
77
- * modules; third-party/stdlib names are not in `localModules`, so they are
78
- * ignored. Sub-dependencies ride along because their files ship too.
79
- *
80
- * @param {Array<{path: string}>} manifestFiles the shipped set.
81
- * @param {(path: string) => (string|null)} readShipped
82
- * @param {Iterable<string>} localModules basenames of the repo's own *.py modules.
83
- * @returns {Array<{file: string, module: string}>} violations (empty = clean).
84
- */
85
- export function auditModuleImports(manifestFiles, readShipped, localModules) {
86
- const shippedMods = new Set(
87
- manifestFiles
88
- .filter((f) => f.path.endsWith('.py'))
89
- .map((f) => f.path.split('/').pop().replace(/\.py$/, '')),
90
- );
91
- const local = new Set(localModules);
92
- const violations = [];
93
- for (const f of manifestFiles) {
94
- if (!f.path.endsWith('.py')) continue;
95
- const body = readShipped(f.path);
96
- if (body == null) continue;
97
- for (const m of body.matchAll(PY_IMPORT)) {
98
- const mod = m[1] ?? m[2];
99
- if (local.has(mod) && !shippedMods.has(mod)) {
100
- violations.push({ file: f.path, module: mod });
101
- }
102
- }
103
- }
104
- return violations;
105
- }
106
-
107
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
108
-
109
- /**
110
- * Sibling guard #2: a shipped skill body must not reference (by skill name)
111
- * another skill the kit does NOT publish. That reference is dangling-on-arrival
112
- * — the consumer is told to invoke a skill that was never installed (the
113
- * `to-issues → codex-adapter-sync` class, before that skill was published).
114
- *
115
- * A "reference" is a NAME match in one of two unambiguous forms — a backtick
116
- * code span (`name` / `/name`) or a slash-command token (`/name`, not preceded
117
- * by a path char). Plain prose and path segments (`scripts/migrations/up`) do
118
- * NOT match, so a word that merely coincides with a skill name (the DB concept
119
- * "migrations") is ignored. Only names KNOWN to the manifest are candidates;
120
- * the violation fires only when that known name is NOT in the publish set.
121
- *
122
- * @param {Array<{path: string, kind: string, ownerSkill?: string}>} manifestFiles shipped set.
123
- * @param {(path: string) => (string|null)} readShipped
124
- * @param {{known: Iterable<string>, published: Iterable<string>}} skills
125
- * @returns {Array<{file: string, skill: string}>} violations (empty = clean).
126
- */
127
- export function auditSkillNameRefs(manifestFiles, readShipped, { known, published }) {
128
- const pub = new Set(published);
129
- // Only known-but-unpublished names can dangle. Each is matched on its own
130
- // delimiter boundary, so a name that is a substring of another (e.g. a skill
131
- // whose name prefixes a longer one) is not falsely subsumed.
132
- const danglers = [...new Set(known)].filter((n) => !pub.has(n));
133
- const violations = [];
134
- for (const f of manifestFiles) {
135
- if (f.kind !== 'skill' || !f.path.endsWith('.md')) continue;
136
- const body = readShipped(f.path);
137
- if (body == null) continue;
138
- for (const name of danglers) {
139
- const e = escapeRe(name);
140
- // backtick span `name`/`/name` OR slash-command /name not inside a path
141
- const re = new RegExp('`/?' + e + '`|(?<![\\w/-])/' + e + '(?![\\w-])');
142
- if (re.test(body)) violations.push({ file: f.path, skill: name });
143
- }
144
- }
145
- return violations;
146
- }
@@ -1,113 +0,0 @@
1
- /**
2
- * scrub(text) — publish-time transforms that strip project-private tokens and
3
- * internal issue/HR references from a skill/script/doc BODY before it lands in
4
- * dist-kit. Pure function; the SOURCE is never modified (build-kit feeds it file
5
- * contents, PLAN scrub model). Generated credit files (LICENSE / NOTICES) are
6
- * NOT scrubbed — build-kit excludes them (Codex R1#2) so legitimate
7
- * maintainer/upstream names survive there.
8
- *
9
- * Three transform classes (PLAN Step 11):
10
- * (a) private identity tokens → neutral placeholders
11
- * (b) internal #NNN / HRn refs → removed, with ONE adjacent space
12
- * (c) project-coupled ../ paths → project-root convention
13
- *
14
- * DESIGN — no global whitespace/punctuation normalization. An early version
15
- * collapsed all double-spaces (corrupting ASCII diagrams + aligned tables),
16
- * trimmed spaces before any `.` (corrupting `an .env`), and removed empty parens
17
- * (which would nuke code `()` like `build_parser()`). Instead each removal eats
18
- * exactly one adjacent space, and the ref-only-paren rule consumes COMPOUND refs
19
- * (`HR7/8`, `#1010/#971`) so it never leaves an empty `()` behind. Pre-existing
20
- * whitespace in untouched text is preserved verbatim.
21
- *
22
- * Negatives that MUST survive (tested): `#<n>` / `<issue-number>` placeholders,
23
- * hex colors (`#0f172a`, `#dc2626`), shell comments (`# foo`), code `#{issue}`
24
- * interpolations and `()`. The `{3,5}`-digit bound on issue refs is what keeps
25
- * those out of the match set.
26
- *
27
- * The fail-closed publish audit (Step 13) is the backstop: any residual ref or
28
- * private token fails the build, so an imperfect scrub never leaks silently.
29
- */
30
-
31
- const ISSUES = String.raw`#\d{3,5}(?:\s*/\s*#?\d{3,5})*`; // issue ref, incl. slash-joined
32
- const REF = String.raw`(?:#\d{3,5}|HR\d+)(?:/\d+)*`; // issue OR HR, compound
33
- // Provenance cross-refs a kit consumer cannot resolve (no docs/adr, no wave
34
- // history). Stripped ONLY in citation position (a ref-only parenthetical) so
35
- // bare-prose fixtures/examples ("Welle 1 covers 9 slices", "contradicts
36
- // ADR-0007 — but", the `Welle 7 — X` parser example) survive untouched — those
37
- // are NOT citation-shaped. Kept in sync with the publish audit's PROVENANCE_DENY.
38
- const PROV = String.raw`(?:ADR-\d{3,4}|Welle \d+|Slice \d+[a-z]?)`;
39
- // Any internal ref token — the union consumed inside a ref-only parenthetical.
40
- const ANYREF = String.raw`(?:#\d{3,5}|HR\d+(?:/\d+)*|${PROV})`;
41
-
42
- // (a) -----------------------------------------------------------------------
43
- function privateTokens(s) {
44
- return s
45
- // deploy domains first (they contain "testreporter") → neutral domain
46
- .replace(/(?:[a-z0-9-]+\.)*iverra\.de/g, '<your-app-domain>')
47
- // repo slug before bare owner/repo
48
- .replace(/iKon85\/Testreporter/g, '<owner>/<repo>')
49
- // home memory path — both `~/` and `$HOME/` forms; the project slug
50
- // (`-home-<user>-projects-<project>`) leaks the username, so the WHOLE slug
51
- // is replaced (before the bare lowercase rule touches it).
52
- .replace(/(~|\$HOME)(\/\.claude\/projects\/)[^/\s)`]+(\/memory)/g, '$1$2<project>$3')
53
- .replace(/\bCoolify\b/g, 'your deploy platform')
54
- .replace(/\biKon85\b/g, '<owner>')
55
- .replace(/\bTestreporter\b/g, '<repo>')
56
- .replace(/\btestreporter\b/g, '<project>')
57
- .replace(/\bNikos\b/g, '<maintainer>s') // German genitive before bare
58
- .replace(/\bNiko\b/g, '<maintainer>');
59
- }
60
-
61
- // (b) -----------------------------------------------------------------------
62
- function internalRefs(s) {
63
- let out = s;
64
- // (b0) prose-suffix `#NNN-Word` → keep the descriptive word, drop the ref
65
- out = out.replace(/#\d{3,5}-(?=\p{L})/gu, '');
66
- // (b1) ref-only parenthetical `(#NNN[, HRn][/#MMM][, Welle N / Slice N][, ADR-####]…)`
67
- // → '' (+ one leading space). ANYREF folds in provenance tokens, so a pure
68
- // citation paren like `(Welle 52 / Slice 3)` or `(Welle 26 / Slice 1g, #983)`
69
- // is consumed whole; a content-bearing paren (`(Welle 3 — 'Backend')`,
70
- // `(Format aus Slice 7)`) keeps a non-ref token → is NOT matched here.
71
- out = out.replace(new RegExp(`[ \\t]?\\(${ANYREF}(?:[\\s,;/]*${ANYREF})*\\)`, 'g'), '');
72
- // (b1a2) paren-INITIAL ref + connective + space + prose: `(#NNN, prose` /
73
- // `(#NNN; prose` → `(prose`. b1c only catches a connective with NO
74
- // following space (`(#NNN/Word`); a `, `/`; ` with a space fell through
75
- // and left a dangling `(, `/`(; ` after b2b stripped just the ref (#1949).
76
- out = out.replace(new RegExp(`\\(${ISSUES}[ \\t]*[,;][ \\t]+`, 'g'), '(');
77
- // (b1b) issue ref joined to PRECEDING text by a connective (`, ` `/ ` `; ` `: `)
78
- // → drop the ref AND the joining separator. Without this, removing a
79
- // mid-sentence ref leaves a dangling `,)` / `(Fix A /):` / `:.` / `, —`.
80
- // Safe because the mandatory `#\d{3,5}` in ISSUES means a bare separator
81
- // with no following ref is never matched (code `a/b`, prose `Verify / DoD`,
82
- // `def f():` all survive — there is no `#NNN` after their separator).
83
- out = out.replace(new RegExp(`[ \\t]*[,/;:][ \\t]*${ISSUES}`, 'g'), '');
84
- // (b1c) issue ref FOLLOWED by a connective + non-space (paren-start `(#NNN/Word…`)
85
- // → drop the ref + that trailing separator. `:` is excluded here so a
86
- // trailing-colon label keeps its colon (`(Lehre #1010/#971: …)` → `(Lehre: …)`).
87
- out = out.replace(new RegExp(`${ISSUES}[ \\t]*[/;,](?=\\S)`, 'g'), '');
88
- // (b2a) inline issue ref WITH a leading space → eat the leading space
89
- out = out.replace(new RegExp(`[ \\t]${ISSUES}`, 'g'), '');
90
- // (b2b) remaining issue ref (line/paren-start) → eat one trailing space
91
- out = out.replace(new RegExp(`${ISSUES}[ \\t]?`, 'g'), '');
92
- // (b3-conn) HR ref joined by a connective → drop ref + separator (mirror of b1b)
93
- out = out.replace(/[ \t]*[,/;:][ \t]*HR\d+(?:\/\d+)*\b/g, '');
94
- // (b3a) HR ref (compound) with a leading space
95
- out = out.replace(/[ \t]HR\d+(?:\/\d+)*\b/g, '');
96
- // (b3b) remaining HR ref → eat one trailing space
97
- out = out.replace(/\bHR\d+(?:\/\d+)*\b[ \t]?/g, '');
98
- return out;
99
- }
100
-
101
- // (c) -----------------------------------------------------------------------
102
- function projectPaths(s) {
103
- // `../../.claude/skills/...` → root-relative (cross-skill reach, PLAN c)
104
- return s.replace(/(?:\.\.\/)+(?=(?:\.claude|\.agents|docs|scripts)\/)/g, '');
105
- }
106
-
107
- export function scrub(text) {
108
- if (!text) return text;
109
- let out = privateTokens(text);
110
- out = internalRefs(out);
111
- out = projectPaths(out);
112
- return out;
113
- }
@@ -1,187 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { scrub } from './scrub.mjs';
4
-
5
- // =========================================================================
6
- // (a) private identity tokens → neutral placeholders
7
- // =========================================================================
8
- test('(a) repo slug + bare owner/repo', () => {
9
- assert.equal(scrub('see iKon85/Testreporter for x'), 'see <owner>/<repo> for x');
10
- assert.equal(scrub('github.com/iKon85/Testreporter/issues/5'),
11
- 'github.com/<owner>/<repo>/issues/5');
12
- assert.equal(scrub('authored by iKon85.'), 'authored by <owner>.');
13
- assert.equal(scrub('the Testreporter board'), 'the <repo> board');
14
- });
15
-
16
- test('(a) deploy platform + domains', () => {
17
- assert.equal(scrub('gemerged → Coolify deployt `main`'),
18
- 'gemerged → your deploy platform deployt `main`');
19
- assert.equal(scrub('curl https://testreporter.iverra.de/version.txt'),
20
- 'curl https://<your-app-domain>/version.txt');
21
- assert.equal(scrub('api.testreporter.iverra.de/health'), '<your-app-domain>/health');
22
- });
23
-
24
- test('(a) person + home memory path', () => {
25
- assert.equal(scrub('↓ (Niko wählt Wellen)'), '↓ (<maintainer> wählt Wellen)');
26
- assert.equal(scrub('das war Nikos Call'), 'das war <maintainer>s Call');
27
- assert.equal(scrub('Note `~/.claude/projects/-home-x-testreporter/memory/foo`'),
28
- 'Note `~/.claude/projects/<project>/memory/foo`');
29
- // $HOME form + the Claude project slug (which embeds the username) must go too
30
- assert.equal(scrub('ls "$HOME/.claude/projects/-home-niko-projects-testreporter/memory/"*.md'),
31
- 'ls "$HOME/.claude/projects/<project>/memory/"*.md');
32
- });
33
-
34
- // =========================================================================
35
- // (b) internal #NNN / HRn refs → removed, surrounding text normalized
36
- // =========================================================================
37
- test('(b) ref-only parenthetical removed cleanly', () => {
38
- assert.equal(scrub('fixed the guard (#824).'), 'fixed the guard.');
39
- assert.equal(scrub('coherence (#983, #1078) before merge'), 'coherence before merge');
40
- assert.equal(scrub('Verify / DoD (HR1): must assert'), 'Verify / DoD: must assert');
41
- assert.equal(scrub('the rule (#1010/#971) holds'), 'the rule holds');
42
- });
43
-
44
- test('(b) refs inside content-bearing paren stay readable', () => {
45
- assert.equal(scrub('(Lehre #1010/#971: erst prüfen)'), '(Lehre: erst prüfen)');
46
- assert.equal(scrub('(#983 Q2). `a`'), '(Q2). `a`');
47
- });
48
-
49
- test('(b) inline prose refs + prose-suffix form', () => {
50
- assert.equal(scrub('the #824 anchor guard'), 'the anchor guard');
51
- assert.equal(scrub('war der #599-Tracker'), 'war der Tracker');
52
- assert.equal(scrub('pro Strang (HR7/8). Sub'), 'pro Strang. Sub');
53
- });
54
-
55
- test('(b) code-embedded ref in a string message', () => {
56
- assert.equal(scrub('verfrüht (#824). `Part of #{parent}`'),
57
- 'verfrüht. `Part of #{parent}`');
58
- });
59
-
60
- test('(b) connective-joined refs drop the dangling separator (no `,)` / `/):` / `:.`)', () => {
61
- // leading `/ ` connective → the slash goes with the ref
62
- assert.equal(scrub('its own slice (Fix A / #1010): foo'), 'its own slice (Fix A): foo');
63
- // leading `, ` connective before a close paren — whole ref-only paren goes
64
- // (Welle/Slice are provenance refs now too, so nothing content-bearing remains)
65
- assert.equal(scrub('(Welle 26 / Slice 1g, #983).'), '.');
66
- // leading `, ` connective before an em-dash
67
- assert.equal(scrub('(provenienz-neutral, #1342 — die Form)'), '(provenienz-neutral — die Form)');
68
- // leading `: ` connective (label: ref)
69
- assert.equal(scrub('Reference output: #1060.'), 'Reference output.');
70
- // trailing connective at paren-start (`(#NNN/Word…`)
71
- assert.equal(scrub('(#1069/Q4=A: tag once → free)'), '(Q4=A: tag once → free)');
72
- // paren-INITIAL ref + connective + SPACE + prose → drop ref+separator, keep `(`
73
- assert.equal(scrub('before any write (#1076, real path only)'),
74
- 'before any write (real path only)');
75
- assert.equal(scrub('in place (#1076; own grammar, not anchor-sync)'),
76
- 'in place (own grammar, not anchor-sync)');
77
- assert.equal(scrub('on every run (#983, proven by a double-run).'),
78
- 'on every run (proven by a double-run).');
79
- });
80
-
81
- test('(b) connective handling preserves real separators with NO adjacent ref', () => {
82
- assert.equal(scrub('Verify / DoD: must assert'), 'Verify / DoD: must assert');
83
- assert.equal(scrub('def parse(): return a/b'), 'def parse(): return a/b');
84
- // a trailing-colon label keeps its colon (the ref before it is removed)
85
- assert.equal(scrub('(Lehre #1010/#971: erst prüfen)'), '(Lehre: erst prüfen)');
86
- });
87
-
88
- // =========================================================================
89
- // (b) NEGATIVES — must be preserved verbatim
90
- // =========================================================================
91
- test('(b) preserves placeholders, hex colors, shell comments', () => {
92
- assert.equal(scrub('Body braucht `Part of #<parent>`'), 'Body braucht `Part of #<parent>`');
93
- assert.equal(scrub('use #<n> and #<anker>'), 'use #<n> and #<anker>');
94
- assert.equal(scrub('bg #0f172a text #dc2626'), 'bg #0f172a text #dc2626');
95
- assert.equal(scrub('color #1e293b;'), 'color #1e293b;');
96
- assert.equal(scrub('# noqa: E402'), '# noqa: E402');
97
- assert.equal(scrub('<issue-number> form'), '<issue-number> form');
98
- // a code interpolation that looks ref-ish must survive
99
- assert.equal(scrub('`closes #{issue}`'), '`closes #{issue}`');
100
- });
101
-
102
- // =========================================================================
103
- // (b-prov) provenance refs (Welle N / Slice N / ADR-####) — citation-shaped only
104
- // =========================================================================
105
- test('(b-prov) ref-only provenance parens are consumed whole', () => {
106
- assert.equal(scrub('key (Welle 52) — literal getter'), 'key — literal getter');
107
- assert.equal(scrub('items (Welle 52 / Slice 3).'), 'items.');
108
- assert.equal(scrub("job (Welle 52 / Slice 1) — this stays"), 'job — this stays');
109
- assert.equal(scrub('the CLI adapter (ADR-0034).'), 'the CLI adapter.');
110
- assert.equal(scrub('`to-waves` (Slice 4) stamps'), '`to-waves` stamps');
111
- assert.equal(scrub('green path (Slice 1).'), 'green path.');
112
- });
113
-
114
- test('(b-prov) mixed provenance+issue paren still consumed', () => {
115
- assert.equal(scrub('coherence (Welle 26 / Slice 1g, #983) before'), 'coherence before');
116
- });
117
-
118
- test('(b-prov) NEGATIVE: bare prose / examples survive verbatim', () => {
119
- // synthetic PRD fixture prose (spec-self-critique/scenarios.md)
120
- assert.equal(scrub('Welle 1 covers 9 slices'), 'Welle 1 covers 9 slices');
121
- assert.equal(scrub("Welle 3 — 'Backend'. Welle 4 — 'Frontend'."),
122
- "Welle 3 — 'Backend'. Welle 4 — 'Frontend'.");
123
- // wave-prefix parser example (board-sync.py) — bare em-dash form
124
- assert.equal(scrub('a title like `fix: Welle 7 — X` only'), 'a title like `fix: Welle 7 — X` only');
125
- // illustrative ADR name in bare prose (improve-codebase-architecture)
126
- assert.equal(scrub('contradicts ADR-0007 — but worth reopening'),
127
- 'contradicts ADR-0007 — but worth reopening');
128
- assert.equal(scrub('Contradicts ADR-0007 (event-sourced orders) — but'),
129
- 'Contradicts ADR-0007 (event-sourced orders) — but');
130
- // template placeholder (wave-anchor-template) — Slice bare, non-ref paren kept
131
- assert.equal(scrub('Slice 1 — <Titel> · empf. <Modell>'), 'Slice 1 — <Titel> · empf. <Modell>');
132
- });
133
-
134
- // =========================================================================
135
- // (c) project-coupled paths → project-root convention
136
- // =========================================================================
137
- test('(c) ../ paths collapse to root convention', () => {
138
- assert.equal(scrub('[X](../../.claude/skills/board-to-waves/SKILL.md)'),
139
- '[X](.claude/skills/board-to-waves/SKILL.md)');
140
- });
141
-
142
- test('(c) preserves relative code imports', () => {
143
- assert.equal(scrub("import { sha256File } from '../lib/hash.mjs';"),
144
- "import { sha256File } from '../lib/hash.mjs';");
145
- assert.equal(scrub("import { update } from './update.mjs';"),
146
- "import { update } from './update.mjs';");
147
- });
148
-
149
- // =========================================================================
150
- // idempotency — scrub(scrub(x)) === scrub(x)
151
- // =========================================================================
152
- test('scrub is idempotent', () => {
153
- const samples = [
154
- 'iKon85/Testreporter (#824) — Niko, Coolify, testreporter.iverra.de',
155
- '(Lehre #1010/#971: erst prüfen) the #824 anchor guard, war der #599-Tracker',
156
- 'keep #<n> #0f172a # comment <issue-number>',
157
- 'pro Strang (HR7/8). DoD (HR1): x',
158
- ];
159
- for (const s of samples) assert.equal(scrub(scrub(s)), scrub(s), `not idempotent: ${s}`);
160
- });
161
-
162
- // =========================================================================
163
- // removal of single-spaced refs is clean (no empty parens, no new doubles)
164
- // =========================================================================
165
- test('removal of single-spaced refs leaves clean text', () => {
166
- const out = scrub('a (#824) b (#983, #1078) c (HR1) d');
167
- assert.equal(out, 'a b c d');
168
- assert.ok(!/\(\s*\)/.test(out), `empty paren in: ${out}`);
169
- });
170
-
171
- // =========================================================================
172
- // ANTI-CORRUPTION — scrub must NOT touch code, filenames, or aligned whitespace
173
- // (the failure modes an early global-normalize version introduced)
174
- // =========================================================================
175
- test('preserves code parens, filename dots, and aligned whitespace', () => {
176
- // code () must never be collapsed (an empty-paren cleanup would have nuked it)
177
- assert.equal(scrub('def foo(): pass'), 'def foo(): pass');
178
- assert.equal(scrub('build_parser().parse_args()'), 'build_parser().parse_args()');
179
- // a leading filename dot must keep its preceding space ("an .env check")
180
- assert.equal(scrub('after an .env/secret check'), 'after an .env/secret check');
181
- assert.equal(scrub('see [x](docs/agents/board-sync.md) here'),
182
- 'see [x](docs/agents/board-sync.md) here');
183
- // intentional alignment / indentation (ASCII diagrams, tables) is preserved
184
- assert.equal(scrub('col1 col2 col3'), 'col1 col2 col3');
185
- assert.equal(scrub('board-to-waves Board → STUB ← HIER'),
186
- 'board-to-waves Board → STUB ← HIER');
187
- });
@@ -1,170 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtemp, mkdir, readFile, readdir, symlink, writeFile } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
-
7
- import {
8
- executeMemoryLifecycle,
9
- planMemoryLifecycle,
10
- runMemoryLifecycle,
11
- } from './index.mjs';
12
-
13
- test('dry-run classifies each candidate once without writing', async () => {
14
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
15
- const activeRoot = join(root, 'active');
16
- const archiveRoot = join(root, 'archive');
17
- await mkdir(archiveRoot, { recursive: true });
18
- await writeFile(join(archiveRoot, 'returning.md'), 'archived memory\n');
19
-
20
- const plan = await planMemoryLifecycle({
21
- activeRoot,
22
- archiveRoot,
23
- candidates: ['new.md', 'returning.md'],
24
- approved: true,
25
- });
26
-
27
- assert.deepEqual(plan.actions.map(({ path, action }) => [path, action]), [
28
- ['new.md', 'create'],
29
- ['returning.md', 'restore'],
30
- ]);
31
- await assert.rejects(readFile(join(activeRoot, 'new.md'), 'utf8'), { code: 'ENOENT' });
32
- await assert.rejects(readFile(join(activeRoot, 'returning.md'), 'utf8'), { code: 'ENOENT' });
33
- });
34
-
35
- test('dry-run refuses unsafe roots, collisions, outside paths, and unapproved restores', async () => {
36
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
37
- const activeRoot = join(root, 'active');
38
- const archiveRoot = join(root, 'archive');
39
- await mkdir(activeRoot, { recursive: true });
40
- await mkdir(archiveRoot, { recursive: true });
41
- await writeFile(join(activeRoot, 'collision.md'), 'active\n');
42
- await writeFile(join(archiveRoot, 'collision.md'), 'different archive\n');
43
- await writeFile(join(archiveRoot, 'approval.md'), 'archived\n');
44
-
45
- const plan = await planMemoryLifecycle({
46
- activeRoot,
47
- archiveRoot,
48
- candidates: ['../foreign.md', 'collision.md', 'approval.md'],
49
- });
50
- assert.deepEqual(plan.actions.map(({ action }) => action), ['refuse', 'refuse', 'refuse']);
51
-
52
- const linkedActive = join(root, 'linked-active');
53
- await symlink(activeRoot, linkedActive, 'dir');
54
- const symlinkPlan = await planMemoryLifecycle({
55
- activeRoot: linkedActive,
56
- archiveRoot,
57
- candidates: ['safe.md'],
58
- approved: true,
59
- });
60
- assert.equal(symlinkPlan.actions[0].action, 'refuse');
61
- });
62
-
63
- test('execute restores without removing the archive and writes a content-free receipt', async () => {
64
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
65
- const activeRoot = join(root, 'active');
66
- const archiveRoot = join(root, 'archive');
67
- const receiptRoot = join(root, 'receipts');
68
- await mkdir(archiveRoot, { recursive: true });
69
- await writeFile(join(archiveRoot, 'returning.md'), 'private memory contents\n');
70
-
71
- const result = await executeMemoryLifecycle({
72
- activeRoot,
73
- archiveRoot,
74
- receiptRoot,
75
- candidates: ['returning.md'],
76
- approved: true,
77
- source: { kitVersion: '1.2.3', bundleVersion: 'bundle-abc' },
78
- });
79
-
80
- assert.equal(await readFile(join(activeRoot, 'returning.md'), 'utf8'), 'private memory contents\n');
81
- assert.equal(await readFile(join(archiveRoot, 'returning.md'), 'utf8'), 'private memory contents\n');
82
- assert.equal(result.verdicts[0].verdict, 'restored');
83
- const [receiptName] = await readdir(receiptRoot);
84
- const receiptText = await readFile(join(receiptRoot, receiptName), 'utf8');
85
- const receipt = JSON.parse(receiptText);
86
- assert.equal(receipt.schemaVersion, 1);
87
- assert.deepEqual(receipt.source, { kitVersion: '1.2.3', bundleVersion: 'bundle-abc' });
88
- assert.match(receipt.verdicts[0].sha256, /^[a-f0-9]{64}$/);
89
- assert.ok(!receiptText.includes('private memory contents'));
90
- });
91
-
92
- test('rerun preserves restored memory and never overwrites or duplicates its receipt', async () => {
93
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
94
- const activeRoot = join(root, 'active');
95
- const archiveRoot = join(root, 'archive');
96
- const receiptRoot = join(root, 'receipts');
97
- await mkdir(archiveRoot, { recursive: true });
98
- await writeFile(join(archiveRoot, 'stable.md'), 'stable\n');
99
- const options = {
100
- activeRoot,
101
- archiveRoot,
102
- receiptRoot,
103
- candidates: ['stable.md'],
104
- approved: true,
105
- source: { kitVersion: '1.2.3', bundleVersion: 'bundle-abc' },
106
- };
107
-
108
- const first = await executeMemoryLifecycle(options);
109
- const receiptBefore = await readFile(first.receiptPath, 'utf8');
110
- const second = await executeMemoryLifecycle(options);
111
-
112
- assert.equal(second.verdicts[0].verdict, 'skipped');
113
- assert.equal(second.receiptPath, null);
114
- assert.deepEqual(await readdir(receiptRoot), [first.receiptPath.split('/').at(-1)]);
115
- assert.equal(await readFile(first.receiptPath, 'utf8'), receiptBefore);
116
- });
117
-
118
- test('disabled and duplicate candidates produce one explicit skip verdict per path', async () => {
119
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
120
- const plan = await planMemoryLifecycle({
121
- activeRoot: join(root, 'active'),
122
- archiveRoot: join(root, 'archive'),
123
- candidates: [
124
- { path: 'later.md', enabled: false },
125
- { path: 'later.md', enabled: false },
126
- ],
127
- approved: true,
128
- });
129
-
130
- assert.deepEqual(plan.actions, [{
131
- path: 'later.md',
132
- action: 'skip',
133
- reason: 'candidate is disabled by consumer policy',
134
- }]);
135
- });
136
-
137
- test('missing or disabled consumer profile is a no-write disabled result', async () => {
138
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
139
-
140
- assert.deepEqual(await runMemoryLifecycle({ projectRoot: root }), {
141
- state: 'disabled',
142
- dryRun: true,
143
- actions: [],
144
- });
145
- await assert.rejects(readFile(join(root, '.memory'), 'utf8'), { code: 'ENOENT' });
146
- });
147
-
148
- test('frozen Testreporter parity profile classifies all three native memory rows', async () => {
149
- const root = await mkdtemp(join(tmpdir(), 'memory-lifecycle-'));
150
- const fixture = JSON.parse(await readFile(new URL(
151
- '../../test/fixtures/memory-lifecycle/testreporter-parity.json',
152
- import.meta.url,
153
- )));
154
- const activeRoot = join(root, fixture.activeRoot);
155
- const archiveRoot = join(root, fixture.archiveRoot);
156
- await mkdir(archiveRoot, { recursive: true });
157
- for (const { path } of fixture.memories) {
158
- await writeFile(join(archiveRoot, path), `${path}\n`);
159
- }
160
-
161
- const plan = await planMemoryLifecycle({
162
- activeRoot,
163
- archiveRoot,
164
- candidates: fixture.memories,
165
- approved: fixture.approvals.restore,
166
- });
167
-
168
- assert.equal(plan.actions.length, 3);
169
- assert.deepEqual(plan.actions.map(({ action }) => action), ['restore', 'restore', 'restore']);
170
- });