@dzhechkov/keysarium 1.7.9 → 1.7.11

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/.dz-manifest.json CHANGED
@@ -9,7 +9,7 @@
9
9
  },
10
10
  {
11
11
  "path": "README.md",
12
- "sha256": "13e7e27322403ce45af5be7df1fa565178b5df73ffeea1b0bbe6b3faddce9f45"
12
+ "sha256": "a81456ef7ae79788d7859aab9a2238c541bb61a41b9d289a27ae6992119538db"
13
13
  },
14
14
  {
15
15
  "path": "README.ru.md",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "path": "package.json",
24
- "sha256": "96bcc52bfe2180fd9976589895f1f328f6c8ac62e24d22042d1105a5c16f4148"
24
+ "sha256": "844c32f67fc8ed8099a388aa53f597a100371c6a693d4b99730a5808593a4843"
25
25
  },
26
26
  {
27
27
  "path": "src/cli.js",
@@ -389,7 +389,7 @@
389
389
  },
390
390
  {
391
391
  "path": "templates/.claude/skills/feature-adr/modules/07-code.md",
392
- "sha256": "db79f8d026cc47edd1e5d2f30d0e1455c55c570dd022d549d440985a3e5940a5"
392
+ "sha256": "d5c03890b2b3a34277c73a14d3d7c97741dec1d6c382e87ee4cc6a5a52d938d7"
393
393
  },
394
394
  {
395
395
  "path": "templates/.claude/skills/feature-adr/modules/08-qe.md",
@@ -487,13 +487,17 @@
487
487
  "path": "templates/.claude/skills/feature-adr/scripts/build-coder-context.mjs",
488
488
  "sha256": "19c3991bfb5e88205c9ff6d4d44037c136a190581bab199d3928196e4616fe15"
489
489
  },
490
+ {
491
+ "path": "templates/.claude/skills/feature-adr/scripts/check-code-targets.mjs",
492
+ "sha256": "bfbad12b54508d5d4c71de640c5e19b3d19317f660e2986d02338ef502f8007e"
493
+ },
490
494
  {
491
495
  "path": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
492
496
  "sha256": "8b93949ce4f671d932c5389050db3a3e2750efcbed69681d69f392ccf4d2a168"
493
497
  },
494
498
  {
495
499
  "path": "templates/.claude/skills/feature-adr/scripts/check-review-convergence.mjs",
496
- "sha256": "80f9691b8e94bf310461aeaaaa23af6e336d41d0962fdf137737639655cbdeb8"
500
+ "sha256": "09c74f1441d9c42603fbb84402e6826cf96535d209be9802e0e4b2fea01dac7d"
497
501
  },
498
502
  {
499
503
  "path": "templates/.claude/skills/feature-adr/scripts/markdown-masker.mjs",
@@ -1001,5 +1005,5 @@
1001
1005
  }
1002
1006
  ]
1003
1007
  },
1004
- "signature": "n1PCRDF3YW7Tcdp+QdGzlK+p7PQGfraCKUO8yQxu6zrvXx6WUALgVsvmfAwh+QXmPZzINT6kYnKmzijtcEkrCw=="
1008
+ "signature": "p0J0fnCC54JAc14pg5UuIgUGtG8u7YLxPlBS6YEXl4qUf5yCqWGZ4g6Bs2Tz8XSfIg1uHkBkAAczkeGaKeBeBw=="
1005
1009
  }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @dzhechkov/keysarium
2
2
 
3
- Current package version: `1.7.9`. <!-- dz:version -->
3
+ Current package version: `1.7.11`. <!-- dz:version -->
4
4
 
5
5
  Site: https://aicoding.space · Source: https://github.com/djd1m/dz-harness/tree/main/packages/@dzhechkov/keysarium
6
6
 
@@ -70,6 +70,11 @@ After installation, open Claude Code in your project directory and start using s
70
70
 
71
71
  ## What You Get
72
72
 
73
+ MEASURED package inventory from `npm pack --dry-run --json` in this package directory.
74
+ Skills are `templates/.claude/skills/*/SKILL.md`; commands are Markdown files in
75
+ `templates/.claude/commands/`. Rules, shards and agent templates are beneath `templates/.claude/`; the library is
76
+ `templates/lib/` and optional documentation is `templates/docs/`. Counts describe bundled files,
77
+ not execution results.
73
78
  | Component | Count | Description |
74
79
  |-----------|-------|-------------|
75
80
  | **Skills** | 12 | `explore`, `frontend-design`, `goap-research-ed25519`, `presentation-storyteller`, `problem-solver-enhanced`, `reverse-engineering-unicorn`, `knowledge-extractor`, `ai-factory-mapper`, `analyst-manual-full`, `edu-site-generator`, `transcript-site-generator`, `feature-adr` |
@@ -84,9 +89,10 @@ After installation, open Claude Code in your project directory and start using s
84
89
  Everything is installed into your project's `.claude/` directory and works natively with Claude Code.
85
90
 
86
91
  > Counts above are MEASURED from the shipped `templates/` tree — reproducer:
87
- > `npx @dzhechkov/keysarium init --dry-run` (the install plan prints the same numbers, computed at
88
- > runtime) or, on a real install, `npx @dzhechkov/keysarium list`. If a count here disagrees with the
89
- > CLI, trust the CLI — it counts files, this table is hand-written.
92
+ > `npx @dzhechkov/keysarium init --dry-run --with-docs` prints these component counts, including
93
+ > optional documentation. On an installed project, `npx @dzhechkov/keysarium list` reports recursive
94
+ > file totals; its skills row counts skill assets, not skill directories, so its units differ from
95
+ > this table. Compare matching units when checking an installed project.
90
96
 
91
97
  ---
92
98
 
@@ -245,7 +251,9 @@ The toolkit automatically detects the domain from the case description and appli
245
251
  - Visual palette: Blue / Navy / Silver
246
252
 
247
253
  ### Retail / E-commerce
248
- - Latency budget: < 200ms for real-time recommendations
254
+ - ESTIMATED example design target: response latency below 200 ms for real-time recommendations.
255
+ This is a planning constraint for a retail case, not measured Keysarium performance; validate it
256
+ against the proposed system and its workload.
249
257
  - A/B testing as primary validation method
250
258
  - Personalization vs. privacy balance (GDPR / FZ-152)
251
259
  - Seasonality and cold-start handling
@@ -526,3 +534,19 @@ Confirmation, scope, family and budget gates still apply. No automatic repair lo
526
534
  Repeated prepare retains all pending paths until verified closure. Actual fallback families bind before dispatch while prior owners remain required. Same-slug QE repair uses current own-reviewer evidence bound to the complete historical checkpoint; original findings/grades remain visible, and failed Confirmation still blocks delivery.
527
535
 
528
536
  Before fallback, host prepare retains independently written pending receipt findings under the original owner even if that reviewer returned null. Unreconciled pending evidence pauses dispatch for originating-reviewer repair; it cannot be replaced by a clean fallback to obtain closure.
537
+
538
+ After a reviewed artifact is renamed or removed, convergence keeps its previously host-bound condition scope in the measured manifest; an absent old path has a null digest. Current discovered paths remain measured. Both prepare and evaluate validate the retained snapshot and reject unbound or unsafe scope paths. The original condition and originating reviewer verification remain required; a rename alone does not close a finding.
539
+
540
+ QE also measures the exact `.dz/guard.json` policy configuration when it is in the current Git index or selected review base. This includes newly staged and intent-to-add files; base-tracked deletions retain a null digest. Both declared targets and discovered changes use this rule. Untracked files absent from that base do not qualify through older history. Other `.dz/` runtime files remain excluded, and existing path, symlink, freshness and reviewer-ownership checks still apply.
541
+
542
+ ### Pre-code target admission
543
+
544
+ Step 7 now runs the installed read-only `scripts/check-code-targets.mjs` before literal context,
545
+ code-cache lookup or any coder route, on every entry including auto/force resume. It reuses the
546
+ colocated review manifest authority: strict targets, circular exclusions, discovered source changes,
547
+ path/Git safety and the exact `.dz/guard.json` index-or-selected-base exception are unchanged.
548
+ Missing planned files remain admissible. Workflow requires admitted JSON plus the actual producer's
549
+ terminal `RC_EXIT:0` witness; failure refuses coding/cache access. Admission preserves review state
550
+ and existing `.fa-state/base-ref` (HEAD only when absent); actual coder initialization never resets
551
+ an existing base. Plain uses the same executable helper and mandatory instructions, without an
552
+ automatic dispatcher. Admission observes current bytes; final QE remains the freshness check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/keysarium",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "scripts": {},
5
5
  "description": "AI Case Research Toolkit for Claude Code — full 7-phase pipeline with feature-adr, dream cycles, background workers, multi-platform support, reward learning, and Cloud.ru AI Factory mapping (--ai-factory flag)",
6
6
  "main": "src/cli.js",
package/sbom.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "13e7e27322403ce45af5be7df1fa565178b5df73ffeea1b0bbe6b3faddce9f45"
28
+ "content": "a81456ef7ae79788d7859aab9a2238c541bb61a41b9d289a27ae6992119538db"
29
29
  }
30
30
  ]
31
31
  },
@@ -59,7 +59,7 @@
59
59
  },
60
60
  {
61
61
  "name": "dz:canonical-json-sha256-v2",
62
- "value": "96bcc52bfe2180fd9976589895f1f328f6c8ac62e24d22042d1105a5c16f4148"
62
+ "value": "844c32f67fc8ed8099a388aa53f597a100371c6a693d4b99730a5808593a4843"
63
63
  }
64
64
  ]
65
65
  },
@@ -979,7 +979,7 @@
979
979
  "hashes": [
980
980
  {
981
981
  "alg": "SHA-256",
982
- "content": "db79f8d026cc47edd1e5d2f30d0e1455c55c570dd022d549d440985a3e5940a5"
982
+ "content": "d5c03890b2b3a34277c73a14d3d7c97741dec1d6c382e87ee4cc6a5a52d938d7"
983
983
  }
984
984
  ]
985
985
  },
@@ -1223,6 +1223,16 @@
1223
1223
  }
1224
1224
  ]
1225
1225
  },
1226
+ {
1227
+ "type": "file",
1228
+ "name": "templates/.claude/skills/feature-adr/scripts/check-code-targets.mjs",
1229
+ "hashes": [
1230
+ {
1231
+ "alg": "SHA-256",
1232
+ "content": "bfbad12b54508d5d4c71de640c5e19b3d19317f660e2986d02338ef502f8007e"
1233
+ }
1234
+ ]
1235
+ },
1226
1236
  {
1227
1237
  "type": "file",
1228
1238
  "name": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
@@ -1239,7 +1249,7 @@
1239
1249
  "hashes": [
1240
1250
  {
1241
1251
  "alg": "SHA-256",
1242
- "content": "80f9691b8e94bf310461aeaaaa23af6e336d41d0962fdf137737639655cbdeb8"
1252
+ "content": "09c74f1441d9c42603fbb84402e6826cf96535d209be9802e0e4b2fea01dac7d"
1243
1253
  }
1244
1254
  ]
1245
1255
  },
@@ -26,6 +26,40 @@ opus (complex code generation)
26
26
 
27
27
  ### 1. Pre-Implementation Checklist
28
28
 
29
+ ### Current target admission (before any coding)
30
+
31
+ After prerequisite gates complete, and on **every** Step-7 entry (including auto/force resume),
32
+ run the installed `scripts/check-code-targets.mjs` beside this module **before** context assembly,
33
+ code-cache lookup, delegation or in-session edits. Set `TARGET_HELPER` to that installation's
34
+ absolute helper path, `EXECUTION_PROJECT` to the absolute execution repository root, and
35
+ `FEATURE_DIR` to its absolute `features/<slug>` directory:
36
+
37
+ ```bash
38
+ node "$TARGET_HELPER" --repo "$EXECUTION_PROJECT" --feature "$FEATURE_DIR"
39
+ ```
40
+
41
+ Require the actual command exit 0 and exactly one `fa-code-targets-1` JSON document with
42
+ `status: "admitted"`, a SHA-256 `manifestDigest` and empty `reasons`. Any missing/broken helper,
43
+ command failure, refused/unavailable status or malformed/contradictory output stops coding and
44
+ cache access; repair the named cause first. Do not substitute a narrated success or saved admission.
45
+ For a host relay, the shell must capture the producer's exit immediately and append one terminal
46
+ `RC_EXIT:<integer>` line; Workflow requires both admitted JSON and observed `RC_EXIT:0` and refuses
47
+ missing/duplicate/conflicting witnesses. Keep stderr separate from the bounded relay.
48
+
49
+ The helper only calls the colocated review checker's `measureManifest(repo, feature, 'qe')`.
50
+ Its strict `- ` target grammar, circular-root exclusions, discovered changed/untracked paths,
51
+ filesystem/Git safety and exact `.dz/guard.json` index-or-selected-base authority remain unchanged.
52
+ K2 remains an additional legacy Markdown-friendly check; admission does not normalize its bullets
53
+ or backticks. Planned missing files/deletions can have null digests. The admission digest records
54
+ this observation, not QE approval or a replacement cache key. Admission never prepares/evaluates
55
+ review lineage or writes source, receipts or `.fa-state`.
56
+
57
+ The selected baseline is existing `.fa-state/base-ref`, otherwise HEAD. Before actual coding,
58
+ initialize HEAD only if that base file is absent; preserve an existing original selection through
59
+ retries/re-entry. Refuse a failed initialization before edits. Admission does not initialize it.
60
+ This is executable Plain guidance with mandatory orchestration, not an automatic Plain dispatcher.
61
+ An unrelated process can change source after admission; ordinary final QE still checks freshness.
62
+
29
63
  ### Current literal context (plain and delegated coding)
30
64
 
31
65
  Before coding or delegating, run the installed helper beside this module. Set `CONTEXT_HELPER` to
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Read-only current source admission. This is neither a QE receipt nor a cache key.
3
+ import { createHash } from 'node:crypto'
4
+ import { realpathSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+ import { measureManifest } from './check-review-convergence.mjs'
7
+
8
+ const schema = 'fa-code-targets-1'
9
+ let result
10
+ try {
11
+ const args = process.argv.slice(2)
12
+ if (args.length !== 4 || args[0] !== '--repo' || !args[1] || args[2] !== '--feature' || !args[3]) throw Error('invalid-arguments: expected --repo <project> --feature <feature>')
13
+ // Match the checker: real execution root, feature resolved from that root, then
14
+ // its own inside-repo/path/Git checks. Do not reinterpret feature from cwd.
15
+ const repo = realpathSync(args[1])
16
+ const feature = resolve(repo, args[3])
17
+ const manifest = measureManifest(repo, feature, 'qe')
18
+ result = { schema, status: 'admitted', manifestDigest: createHash('sha256').update(JSON.stringify(manifest)).digest('hex'), reasons: [] }
19
+ } catch (error) {
20
+ // execFileSync errors include raw subprocess output in message. Keep that out
21
+ // of the wire; ordinary checker diagnostics are bounded and carry no contents.
22
+ const reason = error && (error.stdout !== undefined || error.stderr !== undefined)
23
+ ? 'git-evidence-unavailable: exit=' + String(error.status ?? error.code ?? 'unknown')
24
+ : String(error?.message || 'measurement-unavailable').slice(0, 4096)
25
+ result = { schema, status: 'refused', manifestDigest: null, reasons: [reason] }
26
+ }
27
+ process.stdout.write(JSON.stringify(result) + '\n')
28
+ process.exitCode = result.status === 'admitted' ? 0 : 1
@@ -34,13 +34,25 @@ function reviewerEntry(r, roster) {
34
34
  }
35
35
  function result(phase, verdict, revision, reasons, unresolved = []) { return { schema: SCHEMA, phase, verdict, revision, unresolved, reasons } }
36
36
 
37
+ function artifactPath(rel) {
38
+ return text(rel) && !rel.startsWith('/') && !rel.includes('\\') && !rel.split('/').some(p => !p || p === '.' || p === '..') && !/[\x00-\x1f]/.test(rel)
39
+ }
40
+ function hostProblem(host) {
41
+ if (!dictionary(host, ['schema', 'phase', 'reviewers', 'snapshot', 'conditions', 'reviewSeen', 'rework', 'changedPaths']) || host.schema !== SCHEMA || !phases.includes(host.phase) || !array(host.reviewers, 16) || host.reviewers.length === 0) return 'host-lineage-invalid'
42
+ const owners = host.reviewers.map(r => r?.id)
43
+ if (new Set(owners).size !== owners.length || !host.reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family))) return 'host-reviewers-invalid'
44
+ if (!dictionary(host.snapshot, ['nonce', 'revision', 'manifest']) || !text(host.snapshot.nonce) || !hash(host.snapshot.revision) || !array(host.conditions) || !host.conditions.every(c => condition(c, owners)) || new Set(host.conditions.map(c => c.id)).size !== host.conditions.length || typeof host.reviewSeen !== 'boolean' || typeof host.rework !== 'boolean' || !strings(host.changedPaths) || !host.changedPaths.every(artifactPath)) return 'host-snapshot-invalid'
45
+ const manifest = host.snapshot.manifest
46
+ if (!Array.isArray(manifest) || !manifest.every(p => dictionary(p, ['path', 'digest']) && artifactPath(p.path) && (p.digest === null || hash(p.digest))) || new Set(manifest.map(p => p.path)).size !== manifest.length || digest(JSON.stringify(manifest)) !== host.snapshot.revision) return 'host-manifest-invalid'
47
+ if (host.conditions.some(c => c.scope.some(path => !manifest.some(p => p.path === path)))) return 'host-condition-scope-unbound'
48
+ return null
49
+ }
37
50
  export function evaluateReviewConvergence(host, receipt, currentManifest) {
38
51
  const phase = host?.phase
39
- const refuse = reason => result(phase || 'qe', 'not-established', host?.snapshot?.revision || null, [reason], (host?.conditions || []).map(c => c.id))
40
- if (!dictionary(host, ['schema', 'phase', 'reviewers', 'snapshot', 'conditions', 'reviewSeen', 'rework', 'changedPaths']) || host.schema !== SCHEMA || !phases.includes(phase) || !array(host.reviewers, 16) || host.reviewers.length === 0) return refuse('host-lineage-invalid')
52
+ const refuse = reason => result(phase || 'qe', 'not-established', host?.snapshot?.revision || null, [reason], (array(host?.conditions) ? host.conditions : []).map(c => c?.id))
53
+ const problem = hostProblem(host)
54
+ if (problem) return refuse(problem)
41
55
  const owners = host.reviewers.map(r => r.id)
42
- if (new Set(owners).size !== owners.length || !host.reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family))) return refuse('host-reviewers-invalid')
43
- if (!dictionary(host.snapshot, ['nonce', 'revision', 'manifest']) || !text(host.snapshot.nonce) || !hash(host.snapshot.revision) || !array(host.conditions) || !host.conditions.every(c => condition(c, owners)) || typeof host.reviewSeen !== 'boolean' || typeof host.rework !== 'boolean' || !strings(host.changedPaths)) return refuse('host-snapshot-invalid')
44
56
  if (!same(host.snapshot.manifest, currentManifest) || digest(JSON.stringify(currentManifest)) !== host.snapshot.revision) return refuse('artifact-manifest-changed')
45
57
  if (!dictionary(receipt, ['schema', 'phase', 'nonce', 'revision', 'reviews', 'author']) || receipt.schema !== SCHEMA || receipt.phase !== phase || receipt.nonce !== host.snapshot.nonce || receipt.revision !== host.snapshot.revision) return refuse('receipt-phase-or-revision-invalid')
46
58
  if (!array(receipt.reviews, 16) || receipt.reviews.length !== owners.length || new Set(receipt.reviews.map(r => r?.reviewer)).size !== owners.length) return refuse('reviewer-set-invalid')
@@ -96,12 +108,17 @@ export function evaluateReviewConvergence(host, receipt, currentManifest) {
96
108
  }
97
109
 
98
110
  function safePath(root, rel, allowMissing = true) {
99
- if (!text(rel) || rel.startsWith('/') || rel.includes('\\') || rel.split('/').some(p => !p || p === '.' || p === '..') || /[\x00-\x1f]/.test(rel)) throw Error('unsafe-artifact-path')
111
+ if (!artifactPath(rel)) throw Error('unsafe-artifact-path')
100
112
  let path = root
101
113
  for (const part of rel.split('/')) {
102
114
  path = resolve(path, part)
103
- if (!existsSync(path)) { if (allowMissing) return resolve(root, rel); throw Error('artifact-missing:' + rel) }
104
- if (lstatSync(path).isSymbolicLink()) throw Error('symlink-artifact:' + rel)
115
+ let entry
116
+ try { entry = lstatSync(path) } catch (error) {
117
+ if (error.code !== 'ENOENT') throw error
118
+ if (allowMissing) return resolve(root, rel)
119
+ throw Error('artifact-missing:' + rel)
120
+ }
121
+ if (entry.isSymbolicLink()) throw Error('symlink-artifact:' + rel)
105
122
  }
106
123
  return path
107
124
  }
@@ -134,21 +151,30 @@ function pathsFor(repo, feature, phase) {
134
151
  return match[1]
135
152
  })
136
153
  if (!planned.length) throw Error('target-block-empty')
137
- if (planned.some(p => /^(features|\.dz|\.agentic-qe|roam)\//.test(p))) throw Error('circular-review-target')
138
- paths.push(...planned)
139
- // Additions/deletions are discovered by the host, not the author delta request.
140
154
  const baseFile = resolve(feature, '.fa-state/base-ref')
141
155
  const base = existsSync(baseFile) ? readFileSync(baseFile, 'utf8').trim() : 'HEAD'
142
156
  if (!/^[A-Za-z0-9_./:-]+$/.test(base) || base.startsWith('-')) throw Error('base-ref-invalid')
143
- for (const command of [['diff', '--name-only', '-z', base, '--'], ['ls-files', '--others', '--exclude-standard', '-z']]) {
157
+ const guard = '.dz/guard.json'
158
+ // Exact index membership includes staged additions/intent-to-add. The selected
159
+ // base proves deletions; descendants and unrelated history confer no authority.
160
+ const proof = [['ls-files', '--cached', '-z', '--', guard], ['ls-tree', '-r', '--name-only', '-z', base, '--', guard]].map(command =>
161
+ execFileSync('git', command, { cwd: repo, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }).split('\0').includes(guard))
162
+ const allowedGuard = p => p === guard && proof.some(Boolean)
163
+ if (planned.some(p => /^(features|\.dz|\.agentic-qe|roam)\//.test(p) && !allowedGuard(p))) throw Error('circular-review-target')
164
+ paths.push(...planned)
165
+ // Additions/deletions are discovered by the host, not the author delta request.
166
+ for (const command of [['diff', '--no-renames', '--name-only', '-z', base, '--'], ['ls-files', '--others', '--exclude-standard', '-z']]) {
144
167
  const names = execFileSync('git', command, { cwd: repo, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }).split('\0').filter(Boolean)
145
- paths.push(...names.filter(p => !p.startsWith('features/') && !p.startsWith('.dz/') && !p.startsWith('.agentic-qe/') && !p.startsWith('roam/') && p !== 'architecture/map.json'))
168
+ paths.push(...names.filter(p => allowedGuard(p) || !p.startsWith('features/') && !p.startsWith('.dz/') && !p.startsWith('.agentic-qe/') && !p.startsWith('roam/') && p !== 'architecture/map.json'))
146
169
  }
147
170
  }
148
171
  return [...new Set(paths)].sort()
149
172
  }
150
173
  export function measureManifest(repo, feature, phase) {
151
- return pathsFor(repo, feature, phase).map(path => {
174
+ return measurePaths(repo, pathsFor(repo, feature, phase))
175
+ }
176
+ function measurePaths(repo, paths) {
177
+ return [...new Set(paths)].sort().map(path => {
152
178
  const absolute = safePath(repo, path)
153
179
  if (!existsSync(absolute)) return { path, digest: null }
154
180
  if (!lstatSync(absolute).isFile()) throw Error('non-file-artifact:' + path)
@@ -243,17 +269,27 @@ function bindReviewers(host, requested, measured) {
243
269
  export function runGate({ action, repo, feature, phase, reviewers }) {
244
270
  if (!phases.includes(phase) || !['prepare', 'evaluate'].includes(action)) throw Error('invalid-command')
245
271
  repo = realpathSync(repo); feature = resolve(repo, feature)
246
- const current = measureManifest(repo, feature, phase)
247
272
  const paths = checkpointPaths(repo, feature, phase)
248
- let host = existsSync(paths.host) ? readJson(paths.host) : null
273
+ const hostExists = existsSync(paths.host)
274
+ let host = hostExists ? readJson(paths.host) : null
275
+ if (hostExists) {
276
+ const problem = hostProblem(host)
277
+ if (problem) throw Error(problem)
278
+ if (host.phase !== phase) throw Error('host-lineage-conflict')
279
+ }
280
+ let pending = null
281
+ if (host && existsSync(paths.review)) {
282
+ try { pending = retainPendingReceipt(host, paths.review) } catch (error) { if (action === 'prepare') throw error }
283
+ }
284
+ const retained = pending?.next || host
285
+ // Only scopes bound by the validated prior snapshot can extend current discovery.
286
+ const current = measurePaths(repo, pathsFor(repo, feature, phase).concat((retained?.conditions || []).flatMap(c => c.scope)))
249
287
  const measuredCheckpoint = phase === 'qe' ? qeCheckpoint(repo, feature) : null
250
288
  if (action === 'prepare') {
251
289
  if (!array(reviewers, 16) || !reviewers.length || !reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family)) || new Set(reviewers.map(r => r.id)).size !== reviewers.length) throw Error('reviewers-invalid')
252
- if (host && (host.phase !== phase || host.schema !== SCHEMA || !array(host.conditions) || typeof host.reviewSeen !== 'boolean')) throw Error('host-lineage-conflict')
253
- if (host && existsSync(paths.review)) {
254
- const pending = retainPendingReceipt(host, paths.review)
255
- host = pending.next; writeJson(paths.host, host)
256
- if (pending.problem) throw Error('pending-reviewer-receipt-needs-origin-review')
290
+ if (pending) {
291
+ host = pending.next
292
+ if (pending.problem) { writeJson(paths.host, host); throw Error('pending-reviewer-receipt-needs-origin-review') }
257
293
  }
258
294
  reviewers = bindReviewers(host, reviewers, measuredCheckpoint)
259
295
  const revision = digest(JSON.stringify(current))
@@ -274,21 +310,14 @@ export function runGate({ action, repo, feature, phase, reviewers }) {
274
310
  closure = reconcileCheckpoint(host, receipt, measuredCheckpoint, repo, feature)
275
311
  if (!closure) verdict = result(phase, 'not-established', host.snapshot.revision, ['historical-checkpoint-own-verification-missing'])
276
312
  }
277
- // Preserve independently owned findings even on refusal. Never import author/scribe claims.
278
- // Invalid ownership/meaning refuses without overwriting the prior host authority.
313
+ // Preserve only fresh, independently owned findings bound by the prior snapshot.
314
+ // Stale or malformed receipts cannot originate measurement authority on refusal.
315
+ const preserved = new Map((pending?.next.conditions || host.conditions).map(c => [c.id, c]))
279
316
  const owners = host.reviewers.map(r => r.id)
280
- const preserved = new Map(host.conditions.map(c => [c.id, c]))
281
- if (receipt?.schema === SCHEMA && receipt.phase === phase && array(receipt.reviews)) {
282
- for (const r of receipt.reviews) if (owners.includes(r?.reviewer) && host.reviewers.find(p => p.id === r.reviewer)?.family === r.family && r.independent === true) {
283
- for (const c of [...(array(r.conditions) ? r.conditions : []), ...(array(r.newRisks?.conditions) ? r.newRisks.conditions : [])]) {
284
- if (condition(c, owners) && c.owner === r.reviewer && !preserved.has(c.id)) preserved.set(c.id, c)
285
- }
286
- }
287
- }
288
317
  if (receipt?.phase === phase && receipt.nonce === host.snapshot.nonce && receipt.revision === host.snapshot.revision && risks(receipt.author?.newRisks, owners)) {
289
- for (const c of receipt.author.newRisks.conditions) if (!preserved.has(c.id)) preserved.set(c.id, c)
318
+ for (const c of receipt.author.newRisks.conditions) if (!preserved.has(c.id) && c.scope.every(path => host.snapshot.manifest.some(p => p.path === path))) preserved.set(c.id, c)
290
319
  }
291
- writeJson(paths.host, { ...host, conditions: [...preserved.values()], reviewSeen: true, changedPaths: verdict.verdict === 'closed' ? [] : host.changedPaths })
320
+ writeJson(paths.host, { ...host, conditions: [...preserved.values()], reviewSeen: pending?.next.reviewSeen || host.reviewSeen, changedPaths: verdict.verdict === 'closed' ? [] : host.changedPaths })
292
321
  if (closure && verdict.verdict === 'closed') return { ...verdict, checkpointClosure: closure }
293
322
  return verdict
294
323
  }