@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.
- package/.agents/skills/grill-me/SKILL.md +3 -1
- package/.agents/skills/grill-with-docs/SKILL.md +3 -1
- package/.agents/skills/kit-update/SKILL.md +9 -11
- package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
- package/.agents/skills/setup-workflow/SKILL.md +20 -18
- package/.agents/skills/setup-workflow/board-sync.md +4 -1
- package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.agents/skills/to-issues/SKILL.md +43 -0
- package/.agents/skills/wrapup/SKILL.md +98 -54
- package/.claude/skills/grill-me/SKILL.md +3 -1
- package/.claude/skills/grill-me-codex/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
- package/.claude/skills/kit-update/SKILL.md +9 -11
- package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
- package/.claude/skills/setup-workflow/SKILL.md +20 -18
- package/.claude/skills/setup-workflow/board-sync.md +4 -1
- package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.claude/skills/to-issues/SKILL.md +43 -0
- package/.claude/skills/wrapup/SKILL.md +98 -54
- package/README.md +249 -7
- package/agent-workflow-kit.package.json +42 -42
- package/package.json +16 -12
- package/scripts/marker_lib.py +3 -0
- package/scripts/profile_globs.py +26 -45
- package/scripts/release-delta-guard.mjs +26 -9
- package/scripts/worktree-lifecycle/README.md +57 -160
- package/scripts/worktree-lifecycle/capabilities.json +2 -3
- package/scripts/worktree-lifecycle/classify.py +480 -0
- package/scripts/worktree-lifecycle/cleanup.py +9 -29
- package/scripts/worktree-lifecycle/core.py +68 -1429
- package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
- package/scripts/worktree-lifecycle/profile.py +46 -29
- package/scripts/worktree-lifecycle/setup.py +7 -19
- package/scripts/wrapup-land.py +1292 -545
- package/src/consumer-migrations.json +1 -16
- package/src/lib/bundle.mjs +31 -1
- package/docs/adr/0001-consumer-divergence-policy.md +0 -53
- package/docs/adr/0002-capability-gated-orchestration.md +0 -70
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
- package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
- package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
- package/docs/agents/board-sync.md +0 -74
- package/docs/agents/code-review.md +0 -14
- package/docs/agents/workflow-capabilities.json +0 -28
- package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
- package/docs/research/benchlm-routing-source.md +0 -198
- package/docs/research/consumer-owned-protocol-files.md +0 -238
- package/docs/research/frontend-agent-benchmarks.md +0 -282
- package/docs/research/model-effort-routing-benchmarks.md +0 -261
- package/docs/research/provider-neutral-agent-routing.md +0 -207
- package/docs/research/wave-152-consumer-acceptance.md +0 -98
- package/docs/research/wave-43-script-hook-census.md +0 -128
- package/scripts/build-kit.mjs +0 -117
- package/scripts/build-kit.test.mjs +0 -207
- package/scripts/census/census.test.mjs +0 -252
- package/scripts/census/state.test.mjs +0 -15
- package/scripts/census/transaction.test.mjs +0 -103
- package/scripts/census-contract.test.mjs +0 -227
- package/scripts/check-kit-staleness.mjs +0 -45
- package/scripts/check-kit-staleness.test.mjs +0 -19
- package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
- package/scripts/codex-exec.test.mjs +0 -871
- package/scripts/grill-census-wiring-guard.mjs +0 -87
- package/scripts/grill-census-wiring-guard.test.mjs +0 -107
- package/scripts/kit-release.test.mjs +0 -273
- package/scripts/kit-update-pr.test.mjs +0 -151
- package/scripts/lib/audit-refs.mjs +0 -146
- package/scripts/lib/scrub.mjs +0 -113
- package/scripts/lib/scrub.test.mjs +0 -187
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
- package/scripts/portability_profile_scan.py +0 -53
- package/scripts/release-delta-guard.test.mjs +0 -174
- package/scripts/release-parity.test.mjs +0 -90
- package/scripts/release-state.test.mjs +0 -360
- package/scripts/test_anchor_table.py +0 -69
- package/scripts/test_board_bootstrap.py +0 -348
- package/scripts/test_board_sync.py +0 -208
- package/scripts/test_board_sync_create_idempotency.py +0 -117
- package/scripts/test_board_sync_wave_title.py +0 -120
- package/scripts/test_census_backstop.py +0 -712
- package/scripts/test_census_forward_contract.py +0 -91
- package/scripts/test_census_update_contract.test.mjs +0 -454
- package/scripts/test_codex_adapter_sync_contract.py +0 -169
- package/scripts/test_dist_kit_smoke.py +0 -220
- package/scripts/test_drift_guard_diagnostics.py +0 -295
- package/scripts/test_issue_claim_contract.py +0 -169
- package/scripts/test_kit_docs_language_census.py +0 -178
- package/scripts/test_marker_lib.py +0 -162
- package/scripts/test_orchestrate_wave_contract.py +0 -381
- package/scripts/test_pr_body_check.py +0 -264
- package/scripts/test_profile_globs.py +0 -280
- package/scripts/test_program_planning_contract.py +0 -133
- package/scripts/test_release_authorization_contract.py +0 -101
- package/scripts/test_render_anchor.py +0 -267
- package/scripts/test_retro_wrapup_contract.py +0 -153
- package/scripts/test_skill_code_review_seed.py +0 -89
- package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
- package/scripts/test_skill_frontmatter_lint.py +0 -234
- package/scripts/test_skill_gh_lint.py +0 -124
- package/scripts/test_skill_language_census.py +0 -254
- package/scripts/test_skill_optional_readiness.py +0 -171
- package/scripts/test_skill_portability_lint.py +0 -866
- package/scripts/test_skill_precommit_template.py +0 -30
- package/scripts/test_skill_publish_audit.py +0 -358
- package/scripts/test_skill_readiness_contract.py +0 -264
- package/scripts/test_skill_readiness_preflight.py +0 -180
- package/scripts/test_skill_required_readiness.py +0 -233
- package/scripts/test_skill_selfcontainment_lint.py +0 -255
- package/scripts/test_skill_setup_workflow_seeds.py +0 -691
- package/scripts/test_skill_stale_name_lint.py +0 -126
- package/scripts/test_skill_surface_refs.py +0 -300
- package/scripts/test_skill_trailing_artifact_lint.py +0 -71
- package/scripts/test_tdd_contract.py +0 -78
- package/scripts/test_worktree_ignore_seed.py +0 -320
- package/scripts/test_worktree_setup_base_guard.py +0 -140
- package/scripts/test_worktree_wrapup_contract.py +0 -1737
- package/scripts/test_wrapup_land.py +0 -428
- 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
|
-
}
|
package/scripts/lib/scrub.mjs
DELETED
|
@@ -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
|
-
});
|