@hyperdreamer/pi-webui 1.10.7 → 1.11.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +17 -2
  2. package/dist/cli.js +265 -32
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-BIb2Wx8W.js → CodeViewer-CAf_j27u.js} +1 -1
  5. package/dist/client/assets/{UnifiedDiffViewer-DpWiGlQo.js → UnifiedDiffViewer-wBjEJWa1.js} +1 -1
  6. package/dist/client/assets/{index-DX583DEv.js → index-5EtDb9nj.js} +1197 -476
  7. package/dist/client/index.html +1 -1
  8. package/dist/config.js +52 -11
  9. package/dist/config.js.map +1 -1
  10. package/dist/pi-webui-plugins/workspace-memory/pi-webui-plugin.js +3 -2
  11. package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +1 -0
  12. package/dist/plugin-api.d.ts +30 -0
  13. package/dist/server/app.js +6 -1
  14. package/dist/server/app.js.map +1 -1
  15. package/dist/server/realtime/sessionEventHub.js +40 -5
  16. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  17. package/dist/server/realtime/sessionStatusCoalescer.js +137 -0
  18. package/dist/server/realtime/sessionStatusCoalescer.js.map +1 -0
  19. package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
  20. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  21. package/dist/server/sessiond.js +57 -5
  22. package/dist/server/sessiond.js.map +1 -1
  23. package/dist/server/sessions/modelPolicyCapability.js +88 -0
  24. package/dist/server/sessions/modelPolicyCapability.js.map +1 -0
  25. package/dist/server/sessions/modelPolicyTool.js +31 -0
  26. package/dist/server/sessions/modelPolicyTool.js.map +1 -0
  27. package/dist/server/sessions/modelTierRegistry.js +14 -0
  28. package/dist/server/sessions/modelTierRegistry.js.map +1 -1
  29. package/dist/server/sessions/modelTierSettingsRoutes.js +36 -0
  30. package/dist/server/sessions/modelTierSettingsRoutes.js.map +1 -0
  31. package/dist/server/sessions/modelTierSettingsService.js +100 -0
  32. package/dist/server/sessions/modelTierSettingsService.js.map +1 -0
  33. package/dist/server/sessions/piSessionService.js +1643 -433
  34. package/dist/server/sessions/piSessionService.js.map +1 -1
  35. package/dist/server/sessions/sessionDefaultsRoutes.js +39 -0
  36. package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -1
  37. package/dist/server/sessions/sessionDefaultsService.js +36 -6
  38. package/dist/server/sessions/sessionDefaultsService.js.map +1 -1
  39. package/dist/server/sessions/sessionMetadataStore.js +107 -13
  40. package/dist/server/sessions/sessionMetadataStore.js.map +1 -1
  41. package/dist/server/sessions/sessionModelPolicy.js +118 -0
  42. package/dist/server/sessions/sessionModelPolicy.js.map +1 -0
  43. package/dist/server/sessions/sessionReorder.js +117 -0
  44. package/dist/server/sessions/sessionReorder.js.map +1 -0
  45. package/dist/server/sessions/sessionRouteFastifyOptions.js +8 -0
  46. package/dist/server/sessions/sessionRouteFastifyOptions.js.map +1 -0
  47. package/dist/server/sessions/sessionRoutes.js +208 -3
  48. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  49. package/dist/server/sessions/starterModelPolicyPreferenceStore.js +164 -0
  50. package/dist/server/sessions/starterModelPolicyPreferenceStore.js.map +1 -0
  51. package/dist/server/sessions/utilityModelExtension.js +199 -0
  52. package/dist/server/sessions/utilityModelExtension.js.map +1 -0
  53. package/dist/server/sessions/utilityModelResolver.js +77 -0
  54. package/dist/server/sessions/utilityModelResolver.js.map +1 -0
  55. package/dist/server/sessions/utilityModelSettingsRoutes.js +62 -0
  56. package/dist/server/sessions/utilityModelSettingsRoutes.js.map +1 -0
  57. package/dist/server/sessions/utilityModelSettingsService.js +101 -0
  58. package/dist/server/sessions/utilityModelSettingsService.js.map +1 -0
  59. package/dist/server/skills/optionalSkillInstall.js +69 -0
  60. package/dist/server/skills/optionalSkillInstall.js.map +1 -0
  61. package/dist/server/skills/optionalSkillInstaller.js +148 -0
  62. package/dist/server/skills/optionalSkillInstaller.js.map +1 -0
  63. package/dist/shared/apiTypes.d.ts +129 -3
  64. package/dist/shared/apiTypes.js +10 -0
  65. package/dist/shared/apiTypes.js.map +1 -1
  66. package/dist/shared/capabilities.js +15 -0
  67. package/dist/shared/capabilities.js.map +1 -1
  68. package/dist/shared/federatedRoutes.js +7 -0
  69. package/dist/shared/federatedRoutes.js.map +1 -1
  70. package/docs/assets/pi-webui-desktop.png +0 -0
  71. package/docs/config.md +61 -3
  72. package/docs/plugins.md +93 -16
  73. package/optional-skills/deterministic-subagent-driven-development/SKILL.md +224 -0
  74. package/optional-skills/deterministic-subagent-driven-development/pi-webui-skill.json +28 -0
  75. package/optional-skills/deterministic-subagent-driven-development/prompts/final-reviewer.md +132 -0
  76. package/optional-skills/deterministic-subagent-driven-development/prompts/implementer.md +101 -0
  77. package/optional-skills/deterministic-subagent-driven-development/prompts/re-reviewer.md +60 -0
  78. package/optional-skills/deterministic-subagent-driven-development/prompts/task-reviewer.md +80 -0
  79. package/optional-skills/deterministic-subagent-driven-development/references/capability-contract.md +174 -0
  80. package/optional-skills/deterministic-subagent-driven-development/references/plan-contract.md +268 -0
  81. package/optional-skills/deterministic-subagent-driven-development/references/state-machine.md +177 -0
  82. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/manifest.mjs +258 -0
  83. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/plan-policy.mjs +350 -0
  84. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs +290 -0
  85. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs +1264 -0
  86. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-store.mjs +532 -0
  87. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state +3 -0
  88. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state.mjs +349 -0
  89. package/optional-skills/deterministic-writing-plans/SKILL.md +232 -0
  90. package/optional-skills/deterministic-writing-plans/references/grammar.md +84 -0
  91. package/optional-skills/deterministic-writing-plans/templates/plan-skeleton.md +143 -0
  92. package/package.json +13 -9
@@ -0,0 +1,177 @@
1
+ # SDD State Machine Reference
2
+
3
+ The reducer in `scripts/lib/state-machine.mjs` is the authority. This document
4
+ describes it; it never redefines it. `TRANSITIONS` and `PHASES` are exported and
5
+ the test suite asserts this table against them, so drift fails a test rather than
6
+ misleading a reader.
7
+
8
+ ## Contents
9
+
10
+ - [Two rules that shape everything](#two-rules-that-shape-everything)
11
+ - [Canonical direction of truth](#canonical-direction-of-truth)
12
+ - [Phases](#phases)
13
+ - [Transitions](#transitions)
14
+ - [Counters and their bounds](#counters-and-their-bounds)
15
+ - [The finding ledger](#the-finding-ledger)
16
+ - [Dispatch identity and the ambiguity window](#dispatch-identity-and-the-ambiguity-window)
17
+ - [Recovery authority](#recovery-authority)
18
+
19
+ ## Two rules that shape everything
20
+
21
+ **Recording a result and deciding what it means are separate transitions.** A
22
+ `*-finished` event only pins a bounded artifact and a verdict. A separate,
23
+ explicit controller event selects the next phase. A child's report can never
24
+ choose the phase it leads to.
25
+
26
+ **Nothing is inferred from absence.** Every branch a human would call a judgement
27
+ call requires a persisted ruling naming a decision and a reason. The reducer never
28
+ picks the agreeable option by default.
29
+
30
+ Both rules come from measured failure, not taste. In the recorded baseline, both
31
+ conditions on `post-compaction-illegal-transition` produced the correct phase
32
+ token and then inverted the canonical-artifact rule and invented repair
33
+ mechanisms. One minted a fabricated dispatch key.
34
+
35
+ ## Canonical direction of truth
36
+
37
+ `state.json` is canonical. The progress ledger is an append-only audit trail
38
+ **derived from it**. This direction is not negotiable.
39
+
40
+ Authority for whether work actually happened is Git commits and artifacts on
41
+ disk, inherited from the original SDD skill: trust the ledger and `git log` over
42
+ your own recollection. A lost correlation between a dispatch and a session
43
+ degrades to inspecting commits and reports, never to an unrecoverable run.
44
+
45
+ ## Phases
46
+
47
+ 30 phases. Six are terminal and accept no continuation event.
48
+
49
+ | Group | Phases |
50
+ | --- | --- |
51
+ | Gates | `CAPABILITY_CHECK`, `PLAN_VALIDATE`, `PREFLIGHT_DECISION_REQUIRED`, `WORKSPACE_READY` |
52
+ | Task loop | `IMPLEMENT_DISPATCH_INTENT`, `IMPLEMENT_RUNNING`, `IMPLEMENT_RESULT`, `CONTEXT_REQUIRED`, `CONCERN_DECISION_REQUIRED` |
53
+ | Review loop | `TASK_REVIEW_DISPATCH_INTENT`, `TASK_REVIEW_RUNNING`, `TASK_REVIEW_DECISION` |
54
+ | Fix loop | `FIX_DISPATCH_INTENT`, `FIX_RUNNING`, `REREVIEW_DISPATCH_INTENT`, `REREVIEW_RUNNING`, `TASK_COMPLETE` |
55
+ | Final loop | `FINAL_REVIEW_DISPATCH_INTENT`, `FINAL_REVIEW_RUNNING`, `FINAL_FIX_DISPATCH_INTENT`, `FINAL_FIX_RUNNING`, `FINAL_REREVIEW_DISPATCH_INTENT`, `FINAL_REREVIEW_RUNNING` |
56
+ | Ambiguity | `DISPATCH_AMBIGUOUS` |
57
+ | Terminal | `CAPABILITY_BLOCKED`, `PLAN_INVALID`, `TASK_BLOCKED`, `DISPATCH_MISMATCH_BLOCKED`, `FINAL_BLOCKED`, `COMPLETE` |
58
+
59
+ ## Transitions
60
+
61
+ 61 registered `(phase, event)` pairs. Any pair absent from this table is an
62
+ illegal transition.
63
+
64
+ | Source | Event | Destination |
65
+ | --- | --- | --- |
66
+ | any nonterminal | `recovery-ruling-recorded` | same phase; requires reason and receipt |
67
+ | `CAPABILITY_CHECK` | `capability-confirmed` | `PLAN_VALIDATE` |
68
+ | `CAPABILITY_CHECK` | `capability-missing` | `CAPABILITY_BLOCKED` |
69
+ | `PLAN_VALIDATE` | `plan-valid` | `PLAN_VALIDATE`, validation pinned |
70
+ | `PLAN_VALIDATE` | `plan-invalid` / `plan-conflict` | `PLAN_INVALID` |
71
+ | `PLAN_VALIDATE` | `preflight-clean` | `WORKSPACE_READY` |
72
+ | `PLAN_VALIDATE` | `preflight-conflict` | `PREFLIGHT_DECISION_REQUIRED` |
73
+ | `PREFLIGHT_DECISION_REQUIRED` | `preflight-approved` | `WORKSPACE_READY` |
74
+ | `PREFLIGHT_DECISION_REQUIRED` | `preflight-rejected` | `FINAL_BLOCKED` |
75
+ | `WORKSPACE_READY` | `implement-dispatch-intended` | `IMPLEMENT_DISPATCH_INTENT` |
76
+ | any `*_DISPATCH_INTENT` | `dispatch-started` | matching `*_RUNNING` |
77
+ | any `*_DISPATCH_INTENT` | `dispatch-mismatch` | `DISPATCH_MISMATCH_BLOCKED` |
78
+ | any `*_DISPATCH_INTENT` | `dispatch-window-crossed` | `DISPATCH_AMBIGUOUS` |
79
+ | `DISPATCH_AMBIGUOUS` | `dispatch-ruling-recorded` | the recorded intent's running phase (adopt) or intent phase (reissue) |
80
+ | `IMPLEMENT_RUNNING` | `implementer-finished` | `IMPLEMENT_RESULT` |
81
+ | `IMPLEMENT_RESULT` | `implementer-status-recorded` | status-pinned `IMPLEMENT_RESULT`, `CONTEXT_REQUIRED`, `CONCERN_DECISION_REQUIRED`, or `TASK_BLOCKED` |
82
+ | status-pinned `IMPLEMENT_RESULT` | `task-review-dispatch-intended` | `TASK_REVIEW_DISPATCH_INTENT` |
83
+ | `CONTEXT_REQUIRED` | `context-dispatch-intended` | `IMPLEMENT_DISPATCH_INTENT` |
84
+ | `CONTEXT_REQUIRED` | `context-limit-reached` | `TASK_BLOCKED` |
85
+ | `CONCERN_DECISION_REQUIRED` | `concern-ruling-recorded` | status-pinned `IMPLEMENT_RESULT` or `TASK_BLOCKED` |
86
+ | `TASK_REVIEW_RUNNING` | `task-review-finished` | `TASK_REVIEW_DECISION` |
87
+ | `TASK_REVIEW_DECISION` | `review-approved` | `TASK_COMPLETE` |
88
+ | `TASK_REVIEW_DECISION` | `fix-dispatch-intended` | `FIX_DISPATCH_INTENT` |
89
+ | `TASK_REVIEW_DECISION` | `review-blocked` | `TASK_BLOCKED` |
90
+ | `FIX_RUNNING` | `rereview-dispatch-intended` | `REREVIEW_DISPATCH_INTENT` |
91
+ | `FIX_RUNNING` | `fixer-blocked` | `TASK_BLOCKED` |
92
+ | `REREVIEW_RUNNING` | `rereview-finished` | result-pinned `REREVIEW_RUNNING` |
93
+ | result-pinned `REREVIEW_RUNNING` | `rereview-approved` | `TASK_COMPLETE` |
94
+ | result-pinned `REREVIEW_RUNNING` | `task-park-ruling-recorded` | result-pinned `REREVIEW_RUNNING` |
95
+ | result-pinned `REREVIEW_RUNNING` | `next-fix-dispatch-intended` | `FIX_DISPATCH_INTENT` |
96
+ | result-pinned `REREVIEW_RUNNING` | `rereview-blocked` | `TASK_BLOCKED` |
97
+ | `TASK_COMPLETE` | `next-task-ready` | `WORKSPACE_READY` at the next task |
98
+ | `TASK_COMPLETE` | `final-review-dispatch-intended` | `FINAL_REVIEW_DISPATCH_INTENT` |
99
+ | `FINAL_REVIEW_RUNNING` | `final-review-finished` | result-pinned `FINAL_REVIEW_RUNNING` |
100
+ | result-pinned `FINAL_REVIEW_RUNNING` | `final-complete` | `COMPLETE` |
101
+ | result-pinned `FINAL_REVIEW_RUNNING` | `final-fix-dispatch-intended` | `FINAL_FIX_DISPATCH_INTENT` |
102
+ | result-pinned `FINAL_REVIEW_RUNNING` | `final-blocked` | `FINAL_BLOCKED` |
103
+ | `FINAL_FIX_RUNNING` | `final-rereview-dispatch-intended` | `FINAL_REREVIEW_DISPATCH_INTENT` |
104
+ | `FINAL_FIX_RUNNING` | `final-fixer-blocked` | `FINAL_BLOCKED` |
105
+ | `FINAL_REREVIEW_RUNNING` | `final-rereview-finished` | result-pinned `FINAL_REREVIEW_RUNNING` |
106
+ | result-pinned `FINAL_REREVIEW_RUNNING` | `final-complete` | `COMPLETE` |
107
+ | result-pinned `FINAL_REREVIEW_RUNNING` | `final-park-ruling-recorded` | result-pinned `FINAL_REREVIEW_RUNNING` |
108
+ | result-pinned `FINAL_REREVIEW_RUNNING` | `final-blocked` | `FINAL_BLOCKED` |
109
+
110
+ Every `*-dispatch-intended` event carries a full dispatch intent and enters its
111
+ named intent phase **before** any spawn. Task completion requires spec `PASS` and
112
+ quality `APPROVED` together, with no open load-bearing finding.
113
+
114
+ ## Counters and their bounds
115
+
116
+ | Field | Bound | Notes |
117
+ | --- | --- | --- |
118
+ | `contextAttempts` | 0–2 | A third `NEEDS_CONTEXT` must block. Never touches `fixRound`. |
119
+ | `fixRound` | 0–5 | The fixer tier escalates one rung at round 4 and two at round 5. A load-bearing residual at round 5 blocks. |
120
+ | `finalFixUsed` | one wave | A second final-fix wave is not legal. |
121
+ | `revision` | +1 per transition | Exactly one increment, including recovery rulings. |
122
+ | `recoveryRulings` | unbounded | Counted so interventions are visible in the audit trail. |
123
+
124
+ `currentImplementerTier` is derived **only** from the immutable task index
125
+ captured at initialization, never from a live re-parse.
126
+
127
+ ## The finding ledger
128
+
129
+ Findings are keyed by immutable ID. Severity is recorded at report time and can
130
+ never be re-reported at a different level, so a finding cannot be downgraded on
131
+ its way to being dismissed.
132
+
133
+ `Critical` and `Important` are load-bearing: they open a fix round and can never
134
+ be parked. `Minor` is contestable and may be parked with a persisted ruling that
135
+ names evidence.
136
+
137
+ Reporting is additive and may grow the ledger. Adjudication is a set operation
138
+ that may change dispositions but can neither add nor remove entries, which is the
139
+ retention guarantee: no event can silently drop an open, deferred, or parked
140
+ finding. Every disposition requires evidence.
141
+
142
+ ## Dispatch identity and the ambiguity window
143
+
144
+ `dispatchKey` is controller-owned, composed as
145
+ `<runId>:task-<n>:<role>:attempt-<n>[:round-<n>]`. It is **never** passed to
146
+ `spawn_subsession`, which accepts only `{ prompt, cwd, tier }` and returns
147
+ `{ sessionId, cwd }`.
148
+
149
+ The runtime provides no deduplication, so the key buys **correlation, not
150
+ idempotency**. A crash between the spawn call and the correlation write can orphan
151
+ a child. That window cannot be closed from here; it can only be made visible.
152
+ `DISPATCH_AMBIGUOUS` does exactly that, and leaving it requires an explicit
153
+ ruling: `adopt` names an observed session id, `reissue` sends the stored prompt
154
+ bytes and accepts a possible orphan. The reducer never picks.
155
+
156
+ An intent stores the exact rendered prompt bytes, bounded at 384 KiB, before any
157
+ session exists. Recovery reissues those bytes verbatim and never re-renders,
158
+ because re-rendering couples recovery to renderer output.
159
+
160
+ The typed `tier` selects the model. A leading `Model tier: <tier>` line is a
161
+ human-readable echo with no control effect: absent is fine, and disagreement with
162
+ the typed tier is reported as renderer/formula divergence.
163
+
164
+ ## Recovery authority
165
+
166
+ `recovery-ruling-recorded` is legal in any nonterminal phase, never changes the
167
+ phase, and requires both a reason and a receipt. It exists so an intervention
168
+ appears in the audit trail instead of hiding.
169
+
170
+ Terminal phases accept no continuation event. Recovery from a terminal phase is a
171
+ human decision made outside the run.
172
+
173
+ **Never hand-edit `state.json` or the progress ledger.** Every change goes through
174
+ a transition so the revision, audit line, and validation all advance together. A
175
+ hand-edited state is indistinguishable from a corrupted one, and the reducer is
176
+ built to refuse exactly the kind of plausible-looking repair a stuck controller
177
+ would otherwise invent.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Runtime ownership manifest for the optional deterministic SDD skill.
3
+ *
4
+ * The manifest answers two questions a consumer cannot otherwise answer: which
5
+ * package owns this skill, and whether the runtime tree on disk is the tree that
6
+ * was generated. It deliberately does not answer "is this up to date" -- that
7
+ * comparison belongs to an installer, which knows the installed version.
8
+ *
9
+ * The runtime list is explicit rather than globbed. A glob would silently absorb
10
+ * a new file into the shipped surface; an explicit list makes every addition a
11
+ * reviewed edit.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { isAbsolute, join, resolve } from "node:path";
17
+
18
+ /** Manifest schema version. Bump only for a breaking shape change. */
19
+ export const MANIFEST_SCHEMA_VERSION = 1;
20
+
21
+ /** Canonical skill name. Must match the `name` in `SKILL.md` frontmatter. */
22
+ export const SKILL_NAME = "deterministic-subagent-driven-development";
23
+
24
+ /** Distribution posture. `opt-in` means never auto-registered by the package. */
25
+ export const DISTRIBUTION = "opt-in";
26
+
27
+ /**
28
+ * Hash algorithm identifier.
29
+ *
30
+ * `sha256-path-nul-bytes-v1`: for each runtime file in sorted relative-path
31
+ * order, absorb the UTF-8 relative path, a NUL, the raw file bytes, and a NUL.
32
+ * The NUL delimiters make the encoding unambiguous: without them, a rename that
33
+ * shifted bytes between path and content could preserve the digest.
34
+ */
35
+ export const RUNTIME_HASH_ALGORITHM = "sha256-path-nul-bytes-v1";
36
+
37
+ /**
38
+ * Files that constitute the runtime skill, sorted.
39
+ *
40
+ * Excludes `evals/` and `tests/` by construction: those carry fake capability
41
+ * tools and adversarial prompts that must never reach a consumer's skill tree.
42
+ */
43
+ export const RUNTIME_FILES = Object.freeze([
44
+ "SKILL.md",
45
+ "prompts/final-reviewer.md",
46
+ "prompts/implementer.md",
47
+ "prompts/re-reviewer.md",
48
+ "prompts/task-reviewer.md",
49
+ "references/capability-contract.md",
50
+ "references/plan-contract.md",
51
+ "references/state-machine.md",
52
+ "scripts/lib/manifest.mjs",
53
+ "scripts/lib/plan-policy.mjs",
54
+ "scripts/lib/prompt-renderer.mjs",
55
+ "scripts/lib/state-machine.mjs",
56
+ "scripts/lib/state-store.mjs",
57
+ "scripts/sdd-state",
58
+ "scripts/sdd-state.mjs",
59
+ ]);
60
+
61
+ const SEMVER_PATTERN =
62
+ /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
63
+
64
+ /** Raised for any manifest validation or integrity failure. */
65
+ export class ManifestError extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "ManifestError";
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Reject a runtime entry that is absolute, traverses upward, duplicates another,
74
+ * or names excluded development evidence.
75
+ *
76
+ * Validation runs before any hashing so a rejected list never produces a digest.
77
+ */
78
+ export function assertRuntimeList(runtimeFiles) {
79
+ if (!Array.isArray(runtimeFiles) || runtimeFiles.length === 0) {
80
+ throw new ManifestError("runtime file list must be a non-empty array");
81
+ }
82
+
83
+ const seen = new Set();
84
+ for (const entry of runtimeFiles) {
85
+ if (typeof entry !== "string" || entry.length === 0) {
86
+ throw new ManifestError("runtime entries must be non-empty strings");
87
+ }
88
+ if (isAbsolute(entry) || entry.startsWith("/")) {
89
+ throw new ManifestError(`runtime entry must be relative: ${entry}`);
90
+ }
91
+ if (entry.includes("\\")) {
92
+ throw new ManifestError(`runtime entry must use forward slashes: ${entry}`);
93
+ }
94
+ const segments = entry.split("/");
95
+ if (segments.includes("..") || segments.includes(".") || segments.includes("")) {
96
+ throw new ManifestError(`runtime entry must be a normalized path: ${entry}`);
97
+ }
98
+ if (entry.startsWith("evals/") || entry.startsWith("tests/")) {
99
+ throw new ManifestError(`runtime entry must not ship development evidence: ${entry}`);
100
+ }
101
+ if (seen.has(entry)) {
102
+ throw new ManifestError(`duplicate runtime entry: ${entry}`);
103
+ }
104
+ seen.add(entry);
105
+ }
106
+
107
+ const sorted = [...runtimeFiles].sort();
108
+ if (sorted.some((entry, index) => entry !== runtimeFiles[index])) {
109
+ throw new ManifestError("runtime file list must be sorted");
110
+ }
111
+ return sorted;
112
+ }
113
+
114
+ /**
115
+ * Resolve a runtime entry against the source root.
116
+ *
117
+ * There is no second escape check here on purpose. `assertRuntimeList` runs first
118
+ * on every path into this module and rejects absolute paths, backslashes, and any
119
+ * `..` segment, so a validated entry cannot escape. A redundant guard here would
120
+ * be unreachable, and an unreachable guard is worse than none: no test can pin it,
121
+ * so it silently rots while implying a protection it never performs.
122
+ *
123
+ * Symlinks are a separate matter and deliberately not handled: the runtime list is
124
+ * a frozen constant in this file, not caller input, so there is no untrusted path
125
+ * by which a runtime entry could become a link out of the tree.
126
+ */
127
+ function resolveInside(sourceRoot, relativePath) {
128
+ return join(resolve(sourceRoot), relativePath);
129
+ }
130
+
131
+ /**
132
+ * Compute the runtime digest over the validated, sorted list.
133
+ *
134
+ * Missing files surface as a `ManifestError` rather than a raw ENOENT, because a
135
+ * manifest naming a file that does not exist is a manifest defect.
136
+ */
137
+ export function computeRuntimeHash(sourceRoot, runtimeFiles = RUNTIME_FILES) {
138
+ const sorted = assertRuntimeList(runtimeFiles);
139
+ const hash = createHash("sha256");
140
+ for (const relativePath of sorted) {
141
+ const absolutePath = resolveInside(sourceRoot, relativePath);
142
+ let bytes;
143
+ try {
144
+ bytes = readFileSync(absolutePath);
145
+ } catch (cause) {
146
+ throw new ManifestError(`runtime file is missing: ${relativePath}`, { cause });
147
+ }
148
+ hash.update(Buffer.from(relativePath, "utf8"));
149
+ hash.update(Buffer.from([0]));
150
+ hash.update(bytes);
151
+ hash.update(Buffer.from([0]));
152
+ }
153
+ return hash.digest("hex");
154
+ }
155
+
156
+ /** Read the owning package's name and version, validating the version is semver. */
157
+ function readSourcePackage(packageJsonPath) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
161
+ } catch (cause) {
162
+ throw new ManifestError(`cannot read package manifest: ${packageJsonPath}`, { cause });
163
+ }
164
+ if (typeof parsed.name !== "string" || parsed.name.length === 0) {
165
+ throw new ManifestError("package manifest has no name");
166
+ }
167
+ if (typeof parsed.version !== "string" || !SEMVER_PATTERN.test(parsed.version)) {
168
+ throw new ManifestError(`package version is not valid semver: ${String(parsed.version)}`);
169
+ }
170
+ return { name: parsed.name, version: parsed.version };
171
+ }
172
+
173
+ /** Build the manifest object without writing it. */
174
+ export function buildManifest({ sourceRoot, packageJsonPath, runtimeFiles = RUNTIME_FILES }) {
175
+ const sorted = assertRuntimeList(runtimeFiles);
176
+ const sourcePackage = readSourcePackage(packageJsonPath);
177
+ // Hash last: a validation failure must never leave a digest to be trusted.
178
+ const runtimeHash = computeRuntimeHash(sourceRoot, sorted);
179
+ return {
180
+ schemaVersion: MANIFEST_SCHEMA_VERSION,
181
+ name: SKILL_NAME,
182
+ distribution: DISTRIBUTION,
183
+ sourcePackage,
184
+ runtimeHashAlgorithm: RUNTIME_HASH_ALGORITHM,
185
+ runtimeHash,
186
+ runtimeFiles: sorted,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Write the manifest atomically.
192
+ *
193
+ * The digest is computed fully before the first byte is written, and the file
194
+ * appears via `rename`, so a reader never observes a partial manifest or a
195
+ * placeholder digest. There is no intermediate sentinel hash to leak.
196
+ */
197
+ export function writeManifest({ sourceRoot, packageJsonPath, outputPath, runtimeFiles }) {
198
+ const manifest = buildManifest({ sourceRoot, packageJsonPath, runtimeFiles });
199
+ const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
200
+ const temporaryPath = `${outputPath}.tmp-${process.pid.toString(36)}`;
201
+ writeFileSync(temporaryPath, serialized, { encoding: "utf8", mode: 0o644 });
202
+ renameSync(temporaryPath, outputPath);
203
+ return manifest;
204
+ }
205
+
206
+ /** Parse and structurally validate a manifest file. */
207
+ export function readManifest(manifestPath) {
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
211
+ } catch (cause) {
212
+ throw new ManifestError(`cannot read manifest: ${manifestPath}`, { cause });
213
+ }
214
+ if (parsed.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
215
+ throw new ManifestError(`unsupported manifest schema version: ${String(parsed.schemaVersion)}`);
216
+ }
217
+ if (parsed.name !== SKILL_NAME) {
218
+ throw new ManifestError(`unexpected skill name: ${String(parsed.name)}`);
219
+ }
220
+ if (parsed.distribution !== DISTRIBUTION) {
221
+ throw new ManifestError(`unexpected distribution: ${String(parsed.distribution)}`);
222
+ }
223
+ if (parsed.runtimeHashAlgorithm !== RUNTIME_HASH_ALGORITHM) {
224
+ throw new ManifestError(
225
+ `unsupported runtime hash algorithm: ${String(parsed.runtimeHashAlgorithm)}`,
226
+ );
227
+ }
228
+ if (typeof parsed.runtimeHash !== "string" || !/^[0-9a-f]{64}$/u.test(parsed.runtimeHash)) {
229
+ throw new ManifestError("runtime hash must be 64 lowercase hex characters");
230
+ }
231
+ assertRuntimeList(parsed.runtimeFiles);
232
+ return parsed;
233
+ }
234
+
235
+ /**
236
+ * Recompute the digest from the tree beside the manifest and compare.
237
+ *
238
+ * Returns the manifest and digest on success; throws on mismatch. The source root
239
+ * is the manifest's own directory, so verification cannot be pointed at a
240
+ * different tree by accident.
241
+ */
242
+ export function verifyManifest(manifestPath, sourceRoot) {
243
+ const manifest = readManifest(manifestPath);
244
+ const actual = computeRuntimeHash(sourceRoot, manifest.runtimeFiles);
245
+ if (actual !== manifest.runtimeHash) {
246
+ throw new ManifestError(
247
+ `runtime hash mismatch: manifest records ${manifest.runtimeHash} but the tree hashes to ${actual}`,
248
+ );
249
+ }
250
+ return { manifest, runtimeHash: actual };
251
+ }
252
+
253
+ /** Absolute paths of the manifest's runtime files, for callers that copy the tree. */
254
+ export function runtimeFilePaths(sourceRoot, runtimeFiles = RUNTIME_FILES) {
255
+ return assertRuntimeList(runtimeFiles).map((relativePath) =>
256
+ join(resolve(sourceRoot), relativePath),
257
+ );
258
+ }