peaks-loop 4.0.48 → 4.0.49
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/CHANGELOG.md +20 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/compact-command.js +1 -3
- package/dist/cli/commands/feedback-commands.d.ts +11 -7
- package/dist/cli/commands/feedback-commands.js +49 -17
- package/dist/cli/commands/final-review-commands.js +12 -0
- package/dist/cli/commands/loop-eval-commands.js +22 -6
- package/dist/cli/commands/slice-integrate-commands.js +17 -0
- package/dist/services/artifacts/artifact-prerequisites.js +10 -0
- package/dist/services/artifacts/request-artifact-service.js +59 -38
- package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
- package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
- package/dist/services/code/auto-compact-lifecycle.js +65 -16
- package/dist/services/code/auto-compact-orchestrator.js +119 -19
- package/dist/services/code/compact-event-settle.d.ts +20 -8
- package/dist/services/code/compact-event-settle.js +21 -0
- package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
- package/dist/services/context/auto-compact-types.d.ts +20 -2
- package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
- package/dist/services/feedback/feedback-promotion-service.js +341 -20
- package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
- package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
- package/dist/services/job/job-progress-store.js +18 -3
- package/dist/services/observability/jsonl-store.d.ts +19 -0
- package/dist/services/observability/jsonl-store.js +27 -2
- package/dist/services/observability/observability-service.d.ts +10 -3
- package/dist/services/observability/observability-service.js +16 -3
- package/dist/services/prd/handoff-service.js +43 -0
- package/dist/services/qa/qa-business-review-state.js +19 -5
- package/dist/services/sc/sc-service.d.ts +8 -0
- package/dist/services/sc/sc-service.js +8 -1
- package/dist/services/session/getSessionDir.d.ts +33 -0
- package/dist/services/session/getSessionDir.js +60 -0
- package/dist/services/slice/slice-review-state.js +19 -4
- package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
- package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
- package/dist/services/workflow/pipeline-verify-service.js +23 -10
- package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
- package/dist/shared/runtime-root.d.ts +73 -0
- package/dist/shared/runtime-root.js +77 -0
- package/package.json +5 -5
|
@@ -8,25 +8,30 @@
|
|
|
8
8
|
* primitive behind the `peaks feedback promote` and
|
|
9
9
|
* `peaks feedback check-unpromoted` CLI commands.
|
|
10
10
|
*
|
|
11
|
-
* Promotion tracking convention
|
|
12
|
-
* "promoted" when one of the following is true:
|
|
11
|
+
* Promotion tracking convention — two parts, and BOTH are required:
|
|
13
12
|
*
|
|
14
|
-
* (a)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* (a) A MARKER, either an HTML comment near the top of the body
|
|
14
|
+
* (`<!-- peaks-feedback-promoted: layer=<A|B|C> -->`) or a sibling
|
|
15
|
+
* `.peaks/memory/<name>.promotion.json` sidecar with
|
|
16
|
+
* `{ layer: "A" | "B" | "C", ... }`. Written by `peaks feedback
|
|
17
|
+
* promote` so a single read of the memory file is enough to see the
|
|
18
|
+
* claimed layer.
|
|
18
19
|
*
|
|
19
|
-
* (b)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* (b) The ARTIFACT that layer implies — see `promotionArtifactChecks`.
|
|
21
|
+
* rid 2026-09-14-gate-h-promotion: the marker alone used to count,
|
|
22
|
+
* which made the gate self-certifying, because the only thing a marker
|
|
23
|
+
* proves is that `peaks feedback promote` ran. Every layer-A marker in
|
|
24
|
+
* this repo pointed at `sops/<name>.md`, a file that did not exist and
|
|
25
|
+
* that no engine reads.
|
|
23
26
|
*
|
|
24
|
-
* The comment marker is the SOURCE OF TRUTH for human review; the
|
|
25
|
-
*
|
|
26
|
-
* to mark a feedback memory as promoted.
|
|
27
|
+
* The comment marker is the SOURCE OF TRUTH for human review; the sidecar is
|
|
28
|
+
* the source of truth for the scanner. Neither is evidence on its own.
|
|
27
29
|
*/
|
|
28
30
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
29
31
|
import { dirname, join, resolve } from 'node:path';
|
|
32
|
+
import { registerSop } from '../sop/sop-registry-service.js';
|
|
33
|
+
import { projectRegistryPath, projectSopManifestPath } from '../sop/sop-paths.js';
|
|
34
|
+
import { artifactEvidenceFailure } from './promotion-artifact-evidence.js';
|
|
30
35
|
/**
|
|
31
36
|
* PRD-002b slice 2 — extract magic numbers used by the promotion
|
|
32
37
|
* helper. `slice(0, 5)` was used as a heuristic on the user's body
|
|
@@ -41,7 +46,131 @@ export const PROMOTION_LAYER_DETAILS = [
|
|
|
41
46
|
{ layer: 'B', label: 'peaks-hooks PreToolUse', description: 'Append a matcher to .peaks/.claude-settings-template.json. Tool-call interception.' },
|
|
42
47
|
{ layer: 'C', label: 'mode-gate hardFloorCategory', description: 'Extend HardFloorCategory + shouldPauseAtGate. Always pauses regardless of mode.' }
|
|
43
48
|
];
|
|
49
|
+
/**
|
|
50
|
+
* rid 2026-09-14-gate-h-promotion — what actually backs a promotion.
|
|
51
|
+
*
|
|
52
|
+
* Before this, a promotion was honored on the marker alone (HTML comment or
|
|
53
|
+
* sidecar). Both are written by `peaks feedback promote` and neither proves
|
|
54
|
+
* that anything was enforced: every layer-A promotion in this repo pointed at
|
|
55
|
+
* `sops/<name>.md`, a file that did not exist and that no engine reads. The
|
|
56
|
+
* gate was therefore self-certifying — it read only what the command it tells
|
|
57
|
+
* you to run had written.
|
|
58
|
+
*
|
|
59
|
+
* A promotion is now honored only when its layer's enforcement surface carries
|
|
60
|
+
* the artifact. The three layers keep artifacts in three different shapes, so
|
|
61
|
+
* the check is a small table rather than one rule:
|
|
62
|
+
*
|
|
63
|
+
* - A (peaks-sop gate): a SOP manifest at `.peaks/sops/<id>/sop.json` AND an
|
|
64
|
+
* entry for `<id>` in `.peaks/sops/registry.json`. The registry half is not
|
|
65
|
+
* decoration: `gate-enforce-service.enforceBashCommand` enumerates SOPs via
|
|
66
|
+
* `readRegistry()`, so an unregistered manifest is off the enforcement path
|
|
67
|
+
* no matter how valid it is.
|
|
68
|
+
* - B (peaks-hooks PreToolUse): `.peaks/.claude-settings-template.json` must
|
|
69
|
+
* register the rule inside its `hooks` block. The file always exists, so
|
|
70
|
+
* existence proves nothing — the evidence is the registration inside it.
|
|
71
|
+
* - C (mode-gate hardFloorCategory): `src/services/code/mode-gate.ts` must
|
|
72
|
+
* register the rule in the hard-floor vocabulary, for the same reason. This
|
|
73
|
+
* is the repo's existing convention: the one real layer-C promotion cites
|
|
74
|
+
* its memory by path, from the category's doc block.
|
|
75
|
+
*
|
|
76
|
+
* R2 (2026-09-14-gate-h-promotion): each of those is now a PARSE plus a shape
|
|
77
|
+
* assertion, in `promotion-artifact-evidence.ts`. Every check used to be a
|
|
78
|
+
* `text.includes(<rule>)` over the whole file, which certified a tree that was
|
|
79
|
+
* a refusal — an invalid-JSON registry, a template saying "do NOT add a
|
|
80
|
+
* matcher", a mode-gate line saying the rule is deliberately not a category —
|
|
81
|
+
* because in each case the rule's name was still in the file's bytes. A name in
|
|
82
|
+
* a file is not a registration, and a file that cannot be parsed is a finding,
|
|
83
|
+
* not a permit.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* SOP id used for a feedback memory's layer-A artifact.
|
|
87
|
+
*
|
|
88
|
+
* Prefixed because `peaks-*` SOP ids are reserved for the built-in namespace
|
|
89
|
+
* (`reservedIdReason` in sop-service.ts) and several feedback memories start
|
|
90
|
+
* with `peaks-`, which would make them unregistrable under their own name.
|
|
91
|
+
*/
|
|
92
|
+
export function sopIdForFeedback(memoryName) {
|
|
93
|
+
return `feedback-${memoryName}`;
|
|
94
|
+
}
|
|
95
|
+
/** The artifact(s) and the structural evidence each must carry for `layer` to mean anything. */
|
|
96
|
+
export function promotionArtifactChecks(memoryName, layer) {
|
|
97
|
+
if (layer === 'A') {
|
|
98
|
+
const id = sopIdForFeedback(memoryName);
|
|
99
|
+
return [
|
|
100
|
+
{ path: `.peaks/sops/${id}/sop.json`, evidence: 'sop-manifest', id },
|
|
101
|
+
{ path: '.peaks/sops/registry.json', evidence: 'sop-registry-entry', id }
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
if (layer === 'B') {
|
|
105
|
+
return [{ path: '.peaks/.claude-settings-template.json', evidence: 'hook-registration', id: memoryName }];
|
|
106
|
+
}
|
|
107
|
+
return [{ path: 'src/services/code/mode-gate.ts', evidence: 'hard-floor-category', id: memoryName }];
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Which of `checks` are not satisfied under `projectRoot`. Empty means the
|
|
111
|
+
* promotion is backed by its artifact. Never throws, and never permits: a file
|
|
112
|
+
* that is absent, unreadable, or unparseable is a finding, not a warning —
|
|
113
|
+
* "cannot read the evidence" must not read as "the evidence is good".
|
|
114
|
+
*/
|
|
115
|
+
export function missingArtifacts(checks, projectRoot) {
|
|
116
|
+
const missing = [];
|
|
117
|
+
for (const check of checks) {
|
|
118
|
+
const absolute = resolve(projectRoot, check.path);
|
|
119
|
+
if (!existsSync(absolute)) {
|
|
120
|
+
missing.push(`${check.path} (absent)`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = readFileSync(absolute, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
missing.push(`${check.path} (unreadable)`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const failure = artifactEvidenceFailure(check, text);
|
|
132
|
+
if (failure !== null) {
|
|
133
|
+
missing.push(`${check.path} (${failure})`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return missing;
|
|
137
|
+
}
|
|
44
138
|
const COMMENT_MARKER_RE = /<!--\s*peaks-feedback-promoted:\s*layer=([ABC])\s*-->/;
|
|
139
|
+
/**
|
|
140
|
+
* rid 2026-09-14-gate-h-promotion (classify slice) — the "not to be promoted"
|
|
141
|
+
* declaration.
|
|
142
|
+
*
|
|
143
|
+
* The gate used to know only `has artifact` / `has no artifact`, so a memory that
|
|
144
|
+
* prescribes no action could never pass: promoting it registers a SOP whose only
|
|
145
|
+
* gate is "the source file still exists", which asserts nothing about behaviour.
|
|
146
|
+
* That is a permanent false positive — the old vacuity defect facing the other way.
|
|
147
|
+
*
|
|
148
|
+
* The declaration closes it, but it must not become a way to silence the gate.
|
|
149
|
+
* It differs from the refused grandfather channel (`promotedAt` older than this
|
|
150
|
+
* rule) in that a grandfather exemption is a property of a memory's AGE: every
|
|
151
|
+
* legacy memory has it, it says nothing about content, and nobody has to assert
|
|
152
|
+
* or defend it. This is a bounded claim about the memory's CONTENT:
|
|
153
|
+
*
|
|
154
|
+
* 1. The code is drawn from a closed vocabulary — free text cannot be used.
|
|
155
|
+
* 2. Each code binds to a predicate over the memory's own frontmatter, which
|
|
156
|
+
* the gate recomputes. The declaration may only RESTATE what the memory
|
|
157
|
+
* already says; it cannot introduce a new fact.
|
|
158
|
+
* 3. A reason string is required (the `closedAt` escape hatch beside it has none).
|
|
159
|
+
* 4. Coexisting with a promotion marker is a contradiction and fails, so the
|
|
160
|
+
* channel cannot be used to bury a promotion whose artifact is missing.
|
|
161
|
+
* 5. Exempted memories stay REPORTED via `listPromotionExempt`, so the
|
|
162
|
+
* unpromoted count never drops silently.
|
|
163
|
+
*/
|
|
164
|
+
export const NOT_TO_PROMOTE_CODES = ['non-actionable', 'closed-slice-note'];
|
|
165
|
+
/**
|
|
166
|
+
* What each code's predicate requires the memory to already say. The gate does not
|
|
167
|
+
* and cannot verify that "prescribes no action" is TRUE; it verifies that the
|
|
168
|
+
* declaration agrees with a claim the memory makes on its own.
|
|
169
|
+
*/
|
|
170
|
+
const NOT_TO_PROMOTE_CORROBORATION = {
|
|
171
|
+
'non-actionable': 'frontmatter `scope:` containing `non-actionable`, or `nonActionable: true`',
|
|
172
|
+
'closed-slice-note': 'frontmatter `sourceArtifact:` or `source:` naming the slice it was derived from'
|
|
173
|
+
};
|
|
45
174
|
/**
|
|
46
175
|
* Parse a single `.peaks/memory/<file>.md` into a FeedbackMemory, or
|
|
47
176
|
* `null` when the file is missing / unreadable / not a feedback memory.
|
|
@@ -162,12 +291,49 @@ export function listUnpromotedFeedback(opts) {
|
|
|
162
291
|
// honours the closed state and reports `0 unpromoted` for closed records.
|
|
163
292
|
if (isClosedMemory(join(memoryDir, entry.name)))
|
|
164
293
|
continue;
|
|
294
|
+
// rid 2026-09-14-gate-h-promotion (classify slice): a memory may declare
|
|
295
|
+
// itself out of the gate. A malformed declaration is a FAILURE, not a skip —
|
|
296
|
+
// otherwise "make the fields unusable" would be the quietest way through.
|
|
297
|
+
const declaration = readNotToPromote(join(memoryDir, entry.name));
|
|
298
|
+
if (declaration.kind === 'invalid') {
|
|
299
|
+
out.push({
|
|
300
|
+
name: parsed.name,
|
|
301
|
+
path: parsed.path,
|
|
302
|
+
reason: `not-to-promote declaration rejected: ${declaration.reason}`
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (declaration.kind === 'valid') {
|
|
307
|
+
if (parsed.promotion !== null) {
|
|
308
|
+
out.push({
|
|
309
|
+
name: parsed.name,
|
|
310
|
+
path: parsed.path,
|
|
311
|
+
reason: `carries both a layer ${parsed.promotion.layer} promotion marker and a notToPromote: ${declaration.code} declaration — one of the two claims is false; remove one`
|
|
312
|
+
});
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Deliberately exempt; `listPromotionExempt` reports it so the count is visible.
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
165
318
|
if (parsed.promotion === null) {
|
|
166
319
|
out.push({
|
|
167
320
|
name: parsed.name,
|
|
168
321
|
path: parsed.path,
|
|
169
322
|
reason: 'no promotion marker (comment or sidecar) found — see `peaks feedback promote`'
|
|
170
323
|
});
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
// rid 2026-09-14-gate-h-promotion: a marker is a claim, not evidence. It
|
|
327
|
+
// counts only when the layer's enforcement surface actually carries the
|
|
328
|
+
// artifact. Before this, the marker alone was accepted, so the gate
|
|
329
|
+
// certified whatever the promote command had written and nothing else.
|
|
330
|
+
const missing = missingArtifacts(promotionArtifactChecks(parsed.name, parsed.promotion.layer), opts.projectRoot);
|
|
331
|
+
if (missing.length > 0) {
|
|
332
|
+
out.push({
|
|
333
|
+
name: parsed.name,
|
|
334
|
+
path: parsed.path,
|
|
335
|
+
reason: `marker claims layer ${parsed.promotion.layer} but the artifact is missing: ${missing.join('; ')}`
|
|
336
|
+
});
|
|
171
337
|
}
|
|
172
338
|
}
|
|
173
339
|
return out;
|
|
@@ -209,6 +375,109 @@ function isClosedMemory(filePath) {
|
|
|
209
375
|
}
|
|
210
376
|
return false;
|
|
211
377
|
}
|
|
378
|
+
/** Raw frontmatter text of a memory, or `null` when absent/unreadable. */
|
|
379
|
+
function readFrontmatter(filePath) {
|
|
380
|
+
if (!existsSync(filePath))
|
|
381
|
+
return null;
|
|
382
|
+
let raw;
|
|
383
|
+
try {
|
|
384
|
+
raw = readFileSync(filePath, 'utf8');
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
390
|
+
if (!normalized.startsWith('---\n'))
|
|
391
|
+
return null;
|
|
392
|
+
const endIndex = normalized.indexOf('\n---\n', 4);
|
|
393
|
+
if (endIndex < 0)
|
|
394
|
+
return null;
|
|
395
|
+
return normalized.slice(4, endIndex);
|
|
396
|
+
}
|
|
397
|
+
/** Flat `key: value` lookup over frontmatter text; `null` when unset or empty. */
|
|
398
|
+
function frontmatterValue(frontmatter, key) {
|
|
399
|
+
for (const rawLine of frontmatter.split('\n')) {
|
|
400
|
+
const line = rawLine.trim();
|
|
401
|
+
if (!line.startsWith(`${key}:`))
|
|
402
|
+
continue;
|
|
403
|
+
const value = line.slice(key.length + 1).trim();
|
|
404
|
+
if (value.length === 0 || value === '""' || value === "''")
|
|
405
|
+
return null;
|
|
406
|
+
return value;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
/** Does the memory's own frontmatter already say what `code` claims? */
|
|
411
|
+
function corroborates(code, frontmatter) {
|
|
412
|
+
if (code === 'non-actionable') {
|
|
413
|
+
const scope = frontmatterValue(frontmatter, 'scope');
|
|
414
|
+
if (scope !== null && scope.includes('non-actionable'))
|
|
415
|
+
return true;
|
|
416
|
+
return frontmatterValue(frontmatter, 'nonActionable') === 'true';
|
|
417
|
+
}
|
|
418
|
+
return frontmatterValue(frontmatter, 'sourceArtifact') !== null
|
|
419
|
+
|| frontmatterValue(frontmatter, 'source') !== null;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Read the memory's not-to-promote declaration. `invalid` is returned rather than
|
|
423
|
+
* `none` when the fields are present but unusable, so the gate fails with a reason
|
|
424
|
+
* instead of quietly treating a malformed declaration as "no declaration".
|
|
425
|
+
*/
|
|
426
|
+
export function readNotToPromote(filePath) {
|
|
427
|
+
const frontmatter = readFrontmatter(filePath);
|
|
428
|
+
if (frontmatter === null)
|
|
429
|
+
return { kind: 'none' };
|
|
430
|
+
const code = frontmatterValue(frontmatter, 'notToPromote');
|
|
431
|
+
const reason = frontmatterValue(frontmatter, 'notToPromoteReason');
|
|
432
|
+
if (code === null && reason === null)
|
|
433
|
+
return { kind: 'none' };
|
|
434
|
+
if (code === null) {
|
|
435
|
+
return { kind: 'invalid', reason: '`notToPromoteReason` is set but the `notToPromote` code is missing' };
|
|
436
|
+
}
|
|
437
|
+
if (!NOT_TO_PROMOTE_CODES.includes(code)) {
|
|
438
|
+
return {
|
|
439
|
+
kind: 'invalid',
|
|
440
|
+
reason: `\`notToPromote: ${code}\` is not a recognised code (expected ${NOT_TO_PROMOTE_CODES.join(' | ')})`
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (reason === null) {
|
|
444
|
+
return { kind: 'invalid', reason: `\`notToPromote: ${code}\` has no \`notToPromoteReason\` — an exemption must state its own reason` };
|
|
445
|
+
}
|
|
446
|
+
const typedCode = code;
|
|
447
|
+
if (!corroborates(typedCode, frontmatter)) {
|
|
448
|
+
return {
|
|
449
|
+
kind: 'invalid',
|
|
450
|
+
reason: `\`notToPromote: ${typedCode}\` is not corroborated by the memory's own frontmatter (needs ${NOT_TO_PROMOTE_CORROBORATION[typedCode]})`
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return { kind: 'valid', code: typedCode, reason };
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* The feedback memories that declare themselves out of the gate. Exposed so the
|
|
457
|
+
* gate can REPORT them: an exemption nobody can see is the vacuity this whole
|
|
458
|
+
* channel is required to avoid. Never throws.
|
|
459
|
+
*/
|
|
460
|
+
export function listPromotionExempt(opts) {
|
|
461
|
+
const memoryDir = resolve(opts.projectRoot, '.peaks', 'memory');
|
|
462
|
+
if (!existsSync(memoryDir))
|
|
463
|
+
return [];
|
|
464
|
+
const out = [];
|
|
465
|
+
for (const entry of readdirSync(memoryDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
466
|
+
if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name.startsWith('.'))
|
|
467
|
+
continue;
|
|
468
|
+
const filePath = join(memoryDir, entry.name);
|
|
469
|
+
const parsed = parseFeedbackMemory(filePath);
|
|
470
|
+
if (parsed === null)
|
|
471
|
+
continue;
|
|
472
|
+
const declaration = readNotToPromote(filePath);
|
|
473
|
+
if (declaration.kind !== 'valid')
|
|
474
|
+
continue;
|
|
475
|
+
if (parsed.promotion !== null)
|
|
476
|
+
continue; // contradiction — reported as a violation instead
|
|
477
|
+
out.push({ name: parsed.name, path: parsed.path, code: declaration.code, reason: declaration.reason });
|
|
478
|
+
}
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
212
481
|
/**
|
|
213
482
|
* Generate the code stub for a given layer. Returns a Markdown
|
|
214
483
|
* snippet the LLM / human can paste into the appropriate file. Pure
|
|
@@ -219,8 +488,8 @@ export function generatePromotionStub(opts) {
|
|
|
219
488
|
const { layer, feedbackName } = opts;
|
|
220
489
|
if (layer === 'A') {
|
|
221
490
|
return {
|
|
222
|
-
snippet: `# SOP entry for feedback "${feedbackName}"\n\n<!-- Append the rule + acceptance criteria below. Reference from a new peaks-sop gate. -->\n\n## Rule\n\n${opts.feedbackBody.split('\n').slice(0, RULE_BODY_PREVIEW_LINES).join('\n')}\n\n## Enforcement\n\
|
|
223
|
-
targetFiles: [
|
|
491
|
+
snippet: `# SOP entry for feedback "${feedbackName}"\n\n<!-- Append the rule + acceptance criteria below. Reference from a new peaks-sop gate. -->\n\n## Rule\n\n${opts.feedbackBody.split('\n').slice(0, RULE_BODY_PREVIEW_LINES).join('\n')}\n\n## Enforcement\n\nAuthor the rule's gates in the generated manifest and reference it from .claude/rules/.`,
|
|
492
|
+
targetFiles: [`.peaks/sops/${sopIdForFeedback(feedbackName)}/sop.json`]
|
|
224
493
|
};
|
|
225
494
|
}
|
|
226
495
|
if (layer === 'B') {
|
|
@@ -235,6 +504,37 @@ export function generatePromotionStub(opts) {
|
|
|
235
504
|
targetFiles: ['src/services/code/mode-gate.ts', `tests/unit/services/code/${feedbackName}-hard-floor.test.ts`]
|
|
236
505
|
};
|
|
237
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* Materialize the layer-A artifact: the SOP manifest the engine reads, plus its
|
|
509
|
+
* registry entry. Registration is not optional — `gate-enforce-service` walks
|
|
510
|
+
* `readRegistry()`, so an unregistered manifest enforces nothing.
|
|
511
|
+
*
|
|
512
|
+
* Returns the paths written. `registerSop` lints the manifest first, so a
|
|
513
|
+
* malformed generation throws here rather than leaving a promotion that only
|
|
514
|
+
* looks real.
|
|
515
|
+
*/
|
|
516
|
+
async function generateLayerAArtifact(parsed, projectRoot) {
|
|
517
|
+
const id = sopIdForFeedback(parsed.name);
|
|
518
|
+
const manifestPath = projectSopManifestPath(projectRoot, id);
|
|
519
|
+
const description = parsed.frontmatter.description ?? '';
|
|
520
|
+
const manifest = {
|
|
521
|
+
id,
|
|
522
|
+
name: parsed.name,
|
|
523
|
+
description: `Promoted from feedback memory .peaks/memory/${parsed.name}.md${description.length > 0 ? `: ${description}` : ''}`,
|
|
524
|
+
phases: ['apply'],
|
|
525
|
+
gates: [
|
|
526
|
+
{
|
|
527
|
+
id: 'rule-source-present',
|
|
528
|
+
phase: 'apply',
|
|
529
|
+
check: { type: 'file-exists', path: `.peaks/memory/${parsed.name}.md` }
|
|
530
|
+
}
|
|
531
|
+
]
|
|
532
|
+
};
|
|
533
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
534
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
535
|
+
await registerSop({ id, projectRoot });
|
|
536
|
+
return [manifestPath, projectRegistryPath(projectRoot)];
|
|
537
|
+
}
|
|
238
538
|
/**
|
|
239
539
|
* Write the promotion marker + sidecar. Also writes the envelope to
|
|
240
540
|
* `.peaks/_runtime/<sid>/rd/feedback-promote-<name>.json` for QA
|
|
@@ -245,28 +545,39 @@ export function generatePromotionStub(opts) {
|
|
|
245
545
|
* machine-readable mirror. Both are written; either alone is
|
|
246
546
|
* enough for the scanner.
|
|
247
547
|
*/
|
|
248
|
-
export function promoteFeedback(opts) {
|
|
548
|
+
export async function promoteFeedback(opts) {
|
|
249
549
|
const parsed = parseFeedbackMemory(opts.feedbackPath);
|
|
250
550
|
if (parsed === null) {
|
|
251
551
|
throw new Error(`Not a feedback memory: ${opts.feedbackPath}`);
|
|
252
552
|
}
|
|
553
|
+
// rid 2026-09-14-gate-h-promotion (classify slice): the tool must not create the
|
|
554
|
+
// contradiction the gate rejects — promoting a memory that declares itself out of
|
|
555
|
+
// the gate would print `effective: true` while Gate H reports a contradiction.
|
|
556
|
+
const declaration = readNotToPromote(opts.feedbackPath);
|
|
557
|
+
if (declaration.kind === 'valid') {
|
|
558
|
+
throw new Error(`${opts.feedbackPath} declares \`notToPromote: ${declaration.code}\` — promoting it would contradict that declaration. Remove the declaration first if the memory is a rule after all.`);
|
|
559
|
+
}
|
|
253
560
|
const stub = generatePromotionStub({
|
|
254
561
|
layer: opts.layer,
|
|
255
562
|
feedbackName: parsed.name,
|
|
256
563
|
feedbackBody: parsed.body
|
|
257
564
|
});
|
|
258
565
|
const now = new Date().toISOString();
|
|
566
|
+
const required = promotionArtifactChecks(parsed.name, opts.layer);
|
|
259
567
|
const envelope = {
|
|
260
568
|
name: parsed.name,
|
|
261
569
|
feedbackPath: parsed.path,
|
|
262
570
|
layer: opts.layer,
|
|
263
571
|
layerDetail: PROMOTION_LAYER_DETAILS.find((l) => l.layer === opts.layer)?.label ?? opts.layer,
|
|
264
|
-
generatedFiles:
|
|
572
|
+
generatedFiles: [],
|
|
573
|
+
requiredArtifacts: required.map((check) => check.path),
|
|
574
|
+
effective: false,
|
|
265
575
|
snippet: stub.snippet,
|
|
266
576
|
promotedAt: now,
|
|
267
577
|
promotedBy: opts.promotedBy
|
|
268
578
|
};
|
|
269
579
|
if (opts.dryRun === true) {
|
|
580
|
+
envelope.effective = missingArtifacts(required, opts.projectRoot).length === 0;
|
|
270
581
|
return envelope;
|
|
271
582
|
}
|
|
272
583
|
// 1. Embed comment marker in the memory file.
|
|
@@ -283,23 +594,33 @@ export function promoteFeedback(opts) {
|
|
|
283
594
|
: `${marker}\n${body}`;
|
|
284
595
|
const newContent = normalized.slice(0, endIndex + '\n---\n'.length) + newBody;
|
|
285
596
|
writeFileSync(opts.feedbackPath, newContent, 'utf8');
|
|
597
|
+
envelope.generatedFiles.push(opts.feedbackPath);
|
|
598
|
+
}
|
|
599
|
+
// 2. Layer A: the promotion only means something once the SOP engine can see
|
|
600
|
+
// it. Generated before the sidecar so the sidecar records the full list.
|
|
601
|
+
if (opts.layer === 'A') {
|
|
602
|
+
envelope.generatedFiles.push(...(await generateLayerAArtifact(parsed, opts.projectRoot)));
|
|
286
603
|
}
|
|
287
|
-
//
|
|
604
|
+
// 3. Sidecar (machine-readable mirror).
|
|
288
605
|
const sidecarPath = opts.feedbackPath.replace(/\.md$/, '.promotion.json');
|
|
289
606
|
writeFileSync(sidecarPath, JSON.stringify({
|
|
290
607
|
name: parsed.name,
|
|
291
608
|
layer: opts.layer,
|
|
292
609
|
layerDetail: envelope.layerDetail,
|
|
293
|
-
generatedFiles:
|
|
610
|
+
generatedFiles: envelope.generatedFiles,
|
|
611
|
+
requiredArtifacts: envelope.requiredArtifacts,
|
|
294
612
|
promotedAt: now,
|
|
295
613
|
promotedBy: opts.promotedBy
|
|
296
614
|
}, null, 2), 'utf8');
|
|
297
|
-
|
|
615
|
+
envelope.generatedFiles.push(sidecarPath);
|
|
616
|
+
// 4. Envelope to `.peaks/_runtime/<sid>/rd/`.
|
|
298
617
|
const envelopePath = join(opts.projectRoot, '.peaks', '_runtime', opts.sessionId, 'rd', `feedback-promote-${parsed.name}.json`);
|
|
299
618
|
const envelopeDir = dirname(envelopePath);
|
|
300
619
|
if (!existsSync(envelopeDir)) {
|
|
301
620
|
mkdirSync(envelopeDir, { recursive: true });
|
|
302
621
|
}
|
|
622
|
+
envelope.generatedFiles.push(envelopePath);
|
|
623
|
+
envelope.effective = missingArtifacts(required, opts.projectRoot).length === 0;
|
|
303
624
|
writeFileSync(envelopePath, JSON.stringify(envelope, null, 2), 'utf8');
|
|
304
625
|
return envelope;
|
|
305
626
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rid 2026-09-14-gate-h-promotion (R2; repaired by R8) — what a promotion's
|
|
3
|
+
* artifact has to PROVE.
|
|
4
|
+
*
|
|
5
|
+
* The three layers used to be checked with `text.includes(<rule name>)` over the
|
|
6
|
+
* whole file. A substring test cannot tell "the rule is registered" from "a
|
|
7
|
+
* comment saying the rule is absent", so a refusal tree passed every layer:
|
|
8
|
+
*
|
|
9
|
+
* - layer A: a `.peaks/sops/registry.json` that is not valid JSON, with the id
|
|
10
|
+
* still somewhere in its bytes;
|
|
11
|
+
* - layer B: a settings template whose only mention of the rule reads
|
|
12
|
+
* "do NOT add a matcher for <rule>";
|
|
13
|
+
* - layer C: a `mode-gate.ts` whose only mention reads
|
|
14
|
+
* "// TODO: <rule> is DELIBERATELY NOT a hard-floor category".
|
|
15
|
+
*
|
|
16
|
+
* All three are the same defect facing the other way: an unreadable artifact is
|
|
17
|
+
* treated as a permitted one. Each check below therefore parses its evidence and
|
|
18
|
+
* asserts the SHAPE, and every failure is a finding — never a warning that
|
|
19
|
+
* permits. Nothing here throws: an unparseable file is reported, not swallowed.
|
|
20
|
+
*
|
|
21
|
+
* R8 (same rid) — the same defect survived inside R2's repair, in two places:
|
|
22
|
+
*
|
|
23
|
+
* - layer C matched a member's `doc` by name MENTION, so an adverse line
|
|
24
|
+
* sitting between two vocabulary members became the following member's doc
|
|
25
|
+
* and the verdict flipped on where the comment sat. The predicate is now this
|
|
26
|
+
* repo's own citation form — the memory cited by PATH,
|
|
27
|
+
* `.peaks/memory/<id>.md`, exactly as `mode-gate.ts:41` does — rather than
|
|
28
|
+
* the memory merely named. That citation requirement is what closes the
|
|
29
|
+
* placement dependence; the doc is also read from the comment SPANS attached
|
|
30
|
+
* to the member rather than from the raw slice, which states "code is not a
|
|
31
|
+
* doc" as an invariant rather than relying on the raw slice to honour it;
|
|
32
|
+
* - layer B kept `matcher.includes(id)` / `command.includes(id)` over free
|
|
33
|
+
* text, so `command: "echo 'do NOT add a matcher for rule-x'"` registered a
|
|
34
|
+
* rule by writing a sentence about it. Both fields are now parsed: a matcher
|
|
35
|
+
* is a tool selector, a command is argv.
|
|
36
|
+
*
|
|
37
|
+
* R10 (same rid) — layer C read the `HardFloorCategory` union and the
|
|
38
|
+
* `HARD_FLOOR_CATEGORIES` array as ONE flattened member list, so a member of the
|
|
39
|
+
* union alone satisfied the check. Measured on the R8 bytes: a union-only literal
|
|
40
|
+
* — and a union-only member whose doc cited `.peaks/memory/<id>.md` — were both
|
|
41
|
+
* reported BACKED, while `isHardFloorCategory` (which reads the array) returned
|
|
42
|
+
* false and `shouldPauseAtGate` returned `shouldPause: false`. The gate certified
|
|
43
|
+
* a hard floor that paused nothing, contradicting its own message. Only the array
|
|
44
|
+
* enforces, so only the array is now evidence — for the name and for the citation
|
|
45
|
+
* alike. The union is still READ, for one reason only: a category's doc belongs
|
|
46
|
+
* to the category, and this repo documents `commit-boundary-side-effect` beside
|
|
47
|
+
* the union member while the array enforces it.
|
|
48
|
+
*/
|
|
49
|
+
export type PromotionEvidence =
|
|
50
|
+
/** A SOP manifest: a JSON object whose `id` is the SOP's and whose `gates` is an array. */
|
|
51
|
+
'sop-manifest'
|
|
52
|
+
/** An entry with the SOP's `id` inside `<registry>.sops[]` — what `readRegistry()` enumerates. */
|
|
53
|
+
| 'sop-registry-entry'
|
|
54
|
+
/** A hook registration (hook command) inside `hooks` that runs something named after the rule. */
|
|
55
|
+
| 'hook-registration'
|
|
56
|
+
/** A member of `HARD_FLOOR_CATEGORIES` — the array `isHardFloorCategory` reads — that names the rule. */
|
|
57
|
+
| 'hard-floor-category';
|
|
58
|
+
export type PromotionArtifactCheck = {
|
|
59
|
+
/** Project-relative POSIX path whose CONTENT must carry the evidence. */
|
|
60
|
+
path: string;
|
|
61
|
+
evidence: PromotionEvidence;
|
|
62
|
+
/** The rule id the evidence must name (a SOP id for layer A, a memory name for B and C). */
|
|
63
|
+
id: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The reason `check` is unsatisfied by `text`, or `null` when it is satisfied.
|
|
67
|
+
* Never throws: a file that cannot be parsed yields the reason it could not be.
|
|
68
|
+
*/
|
|
69
|
+
export declare function artifactEvidenceFailure(check: PromotionArtifactCheck, text: string): string | null;
|