@tiphys/kernel 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
@@ -11,18 +11,43 @@
11
11
  * CONSTRAINT C-1, NAMED BECAUSE IT DECIDES THE SHAPE OF THIS MODULE.
12
12
  * "Never read current state from the tail of an append-only log." The stream
13
13
  * at `state/status/stream.jsonl` is the HISTORY and is append-only. The
14
- * current state is `state/status/current.json`, a whole document rewritten
14
+ * current state is `status/current.json`, a whole document rewritten
15
15
  * atomically on every emit. `readCurrent` opens `current.json` and nothing
16
16
  * else; it does not know the stream's path. A truncated, corrupted or
17
17
  * half-written stream therefore cannot change what `tiphys status show`
18
18
  * reports, and that is the property criterion 7 witnesses in both directions.
19
19
  *
20
+ * THE SPLIT (M4-D-13, decided in the M4-P18 plan section). The two documents
21
+ * used to sit side by side under the gitignored `state/` prefix, so the one
22
+ * sentence that says where the pipeline stands did not survive a fleet being
23
+ * reclaimed: AGENTS.md's `fleet-state-commit-discipline` clause names "the
24
+ * state file that says where the pipeline stands" as DURABLE, and an ignored
25
+ * path can be neither committed nor pushed. So the pointer moved OUT of
26
+ * `state/` to a tracked path and the stream stayed, because the stream is
27
+ * history that a restart rebuilds.
28
+ *
29
+ * C-1 IS THE REASON FOR THE SPLIT AND NOT A CONSTRAINT IT HAS TO DODGE.
30
+ * `readCurrent` opens the whole document and has no code path that reaches
31
+ * the stream, so moving the document changes WHERE it lives and changes
32
+ * NOTHING about how current state is read. The pair of witnesses in
33
+ * test/status.test.ts is re-pointed at the new layout and still reddens
34
+ * under the same mutation: `readCurrent` aimed at STREAM_FILE.
35
+ *
20
36
  * ATOMIC REWRITE means write a temp file beside the target and rename. A
21
37
  * rename within one directory is atomic on POSIX, so a reader either sees the
22
38
  * whole previous document or the whole new one and never a partial write.
39
+ * The temp file is created beside the DURABLE document, so the two paths in
40
+ * the rename stay inside one directory after the split.
41
+ */
42
+ /**
43
+ * Where the status files live inside a fleet home, and the two directories
44
+ * are on OPPOSITE sides of the fleet `.gitignore` by design (M4-D-13).
45
+ * `STATUS_DIR` is under the ignored `state/` prefix and holds the rebuilt
46
+ * history; `DURABLE_STATUS_DIR` is tracked and holds the one document a
47
+ * restart must not lose.
23
48
  */
24
- /** Where the status files live inside a fleet home. */
25
49
  export declare const STATUS_DIR: string;
50
+ export declare const DURABLE_STATUS_DIR = "status";
26
51
  export declare const STREAM_FILE: string;
27
52
  export declare const CURRENT_FILE: string;
28
53
  /**
@@ -11,23 +11,48 @@
11
11
  * CONSTRAINT C-1, NAMED BECAUSE IT DECIDES THE SHAPE OF THIS MODULE.
12
12
  * "Never read current state from the tail of an append-only log." The stream
13
13
  * at `state/status/stream.jsonl` is the HISTORY and is append-only. The
14
- * current state is `state/status/current.json`, a whole document rewritten
14
+ * current state is `status/current.json`, a whole document rewritten
15
15
  * atomically on every emit. `readCurrent` opens `current.json` and nothing
16
16
  * else; it does not know the stream's path. A truncated, corrupted or
17
17
  * half-written stream therefore cannot change what `tiphys status show`
18
18
  * reports, and that is the property criterion 7 witnesses in both directions.
19
19
  *
20
+ * THE SPLIT (M4-D-13, decided in the M4-P18 plan section). The two documents
21
+ * used to sit side by side under the gitignored `state/` prefix, so the one
22
+ * sentence that says where the pipeline stands did not survive a fleet being
23
+ * reclaimed: AGENTS.md's `fleet-state-commit-discipline` clause names "the
24
+ * state file that says where the pipeline stands" as DURABLE, and an ignored
25
+ * path can be neither committed nor pushed. So the pointer moved OUT of
26
+ * `state/` to a tracked path and the stream stayed, because the stream is
27
+ * history that a restart rebuilds.
28
+ *
29
+ * C-1 IS THE REASON FOR THE SPLIT AND NOT A CONSTRAINT IT HAS TO DODGE.
30
+ * `readCurrent` opens the whole document and has no code path that reaches
31
+ * the stream, so moving the document changes WHERE it lives and changes
32
+ * NOTHING about how current state is read. The pair of witnesses in
33
+ * test/status.test.ts is re-pointed at the new layout and still reddens
34
+ * under the same mutation: `readCurrent` aimed at STREAM_FILE.
35
+ *
20
36
  * ATOMIC REWRITE means write a temp file beside the target and rename. A
21
37
  * rename within one directory is atomic on POSIX, so a reader either sees the
22
38
  * whole previous document or the whole new one and never a partial write.
39
+ * The temp file is created beside the DURABLE document, so the two paths in
40
+ * the rename stay inside one directory after the split.
23
41
  */
24
42
  import { mkdirSync, renameSync, writeFileSync, appendFileSync } from "node:fs";
25
43
  import { join } from "node:path";
26
44
  import { refuseOpenForWrite, readRegularFileIfPresent } from "./task.js";
27
- /** Where the status files live inside a fleet home. */
45
+ /**
46
+ * Where the status files live inside a fleet home, and the two directories
47
+ * are on OPPOSITE sides of the fleet `.gitignore` by design (M4-D-13).
48
+ * `STATUS_DIR` is under the ignored `state/` prefix and holds the rebuilt
49
+ * history; `DURABLE_STATUS_DIR` is tracked and holds the one document a
50
+ * restart must not lose.
51
+ */
28
52
  export const STATUS_DIR = join("state", "status");
53
+ export const DURABLE_STATUS_DIR = "status";
29
54
  export const STREAM_FILE = join(STATUS_DIR, "stream.jsonl");
30
- export const CURRENT_FILE = join(STATUS_DIR, "current.json");
55
+ export const CURRENT_FILE = join(DURABLE_STATUS_DIR, "current.json");
31
56
  /**
32
57
  * The five supervisor-actionable states, blueprint section 5. Duplicated
33
58
  * from `schemas/status-line.schema.json` so the CLI can reject a bad state
@@ -66,8 +91,12 @@ export function makeStatusRecord(input) {
66
91
  * names an event no record supports.
67
92
  */
68
93
  export function emitStatus(fleetRoot, record) {
69
- const directory = join(fleetRoot, STATUS_DIR);
70
- mkdirSync(directory, { recursive: true });
94
+ /* TWO DIRECTORIES SINCE THE SPLIT, and both are created before either is
95
+ written: the ephemeral one is rebuilt by `tiphys resume` after a reclaim
96
+ and the durable one comes across in a clone, so on any given fleet home
97
+ either can be the one that is absent. */
98
+ mkdirSync(join(fleetRoot, STATUS_DIR), { recursive: true });
99
+ mkdirSync(join(fleetRoot, DURABLE_STATUS_DIR), { recursive: true });
71
100
  const streamPath = join(fleetRoot, STREAM_FILE);
72
101
  const currentPath = join(fleetRoot, CURRENT_FILE);
73
102
  const temporaryPath = `${currentPath}.tmp`;
@@ -1,4 +1,5 @@
1
1
  import type { Fleet } from "./fleet.ts";
2
+ import { classifyPathEntry as classifyEntry, readRegularPathIfPresent as readRegularFileIfPresent, refuseOpenPathForWrite as refuseOpenForWrite } from "./fleet.ts";
2
3
  /**
3
4
  * Task state (kernel plan v1, M1-P4 step 1). A task's durable state is
4
5
  * <fleet>/tasks/<id>/, holding plain JSON per plan decision D-3:
@@ -65,65 +66,210 @@ import type { Fleet } from "./fleet.ts";
65
66
  * the window is now the only way to reach the block rather than the
66
67
  * default path to it.
67
68
  */
68
- export type EntryClass =
69
- /** Nothing at the path. */
70
- {
71
- kind: "absent";
72
- }
73
- /** A link is there and resolves to nothing: it exists, and it is empty of evidence. */
74
- | {
75
- kind: "dangling";
76
- }
77
- /** Safe to open. */
78
- | {
79
- kind: "regular";
80
- }
81
- /** Present, and opening it is not safe: never opened, always named. */
82
- | {
83
- kind: "irregular";
84
- reason: string;
85
- }
86
- /** Neither lstat nor stat could answer the question. */
87
- | {
88
- kind: "unexaminable";
89
- reason: string;
90
- };
91
69
  /**
92
- * THE ONE ANSWER TO "may this path be opened". Every reader and every
93
- * writer of a path this kernel does not itself guarantee to be a regular
94
- * file goes through this, so there is one implementation of the question
95
- * and not one per call site.
70
+ * ONE IMPLEMENTATION, RE-EXPORTED, AND THAT IS THE DR-0047 SWEEP ROUND 2
71
+ * CONSOLIDATION.
72
+ *
73
+ * The paragraphs above explain WHY the probe exists and they still hold. What
74
+ * changed is where it lives. Until this round there were TWO byte-equivalent
75
+ * copies of it: `classifyEntry`/`readRegularFileIfPresent`/`refuseOpenForWrite`
76
+ * here, and `classifyPathEntry`/`readRegularPathIfPresent`/
77
+ * `refuseOpenPathForWrite` in src/fleet.ts, with identical types, identical
78
+ * bodies and identical sentences. Round 1 created the second deliberately and
79
+ * both implementers flagged it: src/lock.ts and src/exclusion.ts sit BELOW this
80
+ * module in the import graph, so importing from here would have made a cycle.
81
+ *
82
+ * THE DIRECTION THAT REMOVES THE COPY WITHOUT MAKING A CYCLE IS DOWNWARD, AND
83
+ * IT IS THE ONE THE COMMENT ABOVE ALREADY ASKED FOR. src/fleet.ts imports NO
84
+ * local module at all, so it is the leaf; this module already imports
85
+ * `Fleet` from it. The chain is task -> fleet, and lock -> fleet, and nothing
86
+ * points back. The alternative, making lock and exclusion call into here, is
87
+ * the cycle task -> lock -> task and is what round 1 correctly refused.
88
+ *
89
+ * THE NAMES HERE ARE KEPT AS ALIASES rather than the 88 call sites in 31 files
90
+ * being rewritten. The names are the whole reason the rewrite would be
91
+ * expensive and none of the reason the duplication was a defect: what mattered
92
+ * was two bodies that could drift, and there is now one body.
96
93
  */
97
- export declare function classifyEntry(path: string): EntryClass;
94
+ export type { PathEntryClass as EntryClass, RegularPathRead as RegularRead } from "./fleet.ts";
95
+ export { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite };
96
+ export type TaskShape = "ship" | "scout";
97
+ export type TaskStatus = "open" | "closed";
98
98
  /**
99
- * Refuse an open-for-WRITE of a path that is not a regular file. The
100
- * hazard is symmetric: open(2) for writing on a FIFO with no reader blocks
101
- * exactly as reading one with no writer does, so a staged write and an
102
- * append are as dangerous as a read. Returns the reason, or undefined when
103
- * the path may be opened (absent included: creating it is the point).
99
+ * WHOSE AUTHORITY A PAYLOAD RUNS UNDER (M4-P8 step 2).
100
+ *
101
+ * `orchestrator` is the process that plans, reviews and merges, and is the
102
+ * only payload class the declared credential escape hatch was ever written
103
+ * for (SpawnOptions.allowPrCredentials, M2-P8 criterion 1: "for the
104
+ * orchestrator's own spawns, never for an implementer payload"). `project`
105
+ * is every payload that works inside a project clone.
106
+ *
107
+ * It is DECLARED, never derived. The kernel cannot infer it from a command,
108
+ * a role or a branch without holding a vocabulary for one of those, and a
109
+ * derived answer would be wrong in the one direction that matters.
104
110
  */
105
- export declare function refuseOpenForWrite(path: string): string | undefined;
106
- /** What a guarded read of a possibly-absent path produced. */
107
- export type RegularRead = {
108
- kind: "read";
109
- body: string;
110
- } | {
111
- kind: "absent";
111
+ export type PayloadClass = "orchestrator" | "project";
112
+ /**
113
+ * One granted allowlist extension, as it is recorded in meta.json.
114
+ *
115
+ * BUILT THROUGH THE ACCESSORS, NEVER BY READING `entry.name` DIRECTLY
116
+ * (CR-B-002, the half of it that is about the record). A bare-string entry
117
+ * has no `name` property, so `{name: entry.name, reason: entry.reason}`
118
+ * produced the literal record `{}`: an operator opening the task directory
119
+ * could see THAT a widening happened and not WHICH name was widened. The
120
+ * record is now built with `extensionName` / `extensionReason`
121
+ * (src/exec/env.ts), which read both forms.
122
+ *
123
+ * `reason` is OPTIONAL HERE AND REQUIRED ON THE AUDITED ROUTE, and the
124
+ * difference is the point rather than an inconsistency. This type describes
125
+ * what was actually recorded; the audited route refuses an entry with no
126
+ * usable reason before this record is built, so an entry reaching meta.json
127
+ * without one came through a seam that does not demand one. Recording the
128
+ * absence AS an absence (the key is simply not present in the JSON) is what
129
+ * keeps it distinguishable from a blank reason. Defaulting it to `""` would
130
+ * substitute a valid-looking value for a missing one, which is the same
131
+ * mechanism as CR-B-002 wearing a record's clothes.
132
+ */
133
+ export interface CredentialExtensionRecord {
134
+ name: string;
135
+ reason?: string;
112
136
  }
113
- /** Present and not readable, with a reason naming the path. */
114
- | {
115
- kind: "refused";
116
- reason: string;
117
- };
118
137
  /**
119
- * THE ONE READ of a file that might not be there and might not be a file.
120
- * src/task.ts, src/liveness.ts, src/watcher.ts and src/commands/doctor.ts
121
- * all read fleet state through this, so "probe before open" is a property
122
- * of the read and cannot be forgotten by a new caller.
138
+ * THE HANDOVER COMPARISON (M4-P8 criterion 6): what the kernel handed the
139
+ * adapter against what actually reached the child.
140
+ *
141
+ * TWO PROPERTIES, AND THE STATUS WORD NAMES WHICH OF THEM WERE CHECKED.
142
+ * That is finding CR-B-001 (clean-room-retro-B-criteria, HIGH) and it is
143
+ * worth stating as a mechanism rather than as one word: `status: "compared"`
144
+ * used to be written after comparing NAME SETS ONLY, while an operator reads
145
+ * "compared" as "the handover was verified". An adapter that keeps the name
146
+ * set byte-identical and merely restores `HOME` and `XDG_CONFIG_HOME` to
147
+ * their real paths defeats the whole M2R-004 defense, because that defense
148
+ * works by REDIRECTING those names rather than by dropping them, and the
149
+ * record then POSITIVELY ASSERTED a clean handover while the child read the
150
+ * real gh credential store. The general shape is a record whose status word
151
+ * is stronger than the check behind it.
152
+ *
153
+ * Both halves of the repair are here. The word is weakened so it can only
154
+ * say what was checked, and the check is strengthened so the pointer VALUES
155
+ * are compared too.
156
+ *
157
+ * compared names AND the five CREDENTIAL_STORE_REDIRECTIONS
158
+ * pointers were both in hand and both compared.
159
+ * names-compared the name sets were compared and NO pointer evidence
160
+ * was available. This is the value the old `compared`
161
+ * should have carried: it is a true statement about one
162
+ * property of two, and it never reads as a clean bill.
163
+ * pointers-compared the pointers were observed in the child and compared,
164
+ * and the adapter reported no name set. Reachable with
165
+ * any adapter written before M4-P8, which reports
166
+ * nothing, running under the current turn-end hook.
167
+ * unreported neither property could be checked: no reported name
168
+ * set and no pointer evidence.
169
+ * not-applicable the spawn ran under the declared escape hatch, so the
170
+ * kernel handed over no environment at all and there is
171
+ * nothing a difference could be measured against.
172
+ *
173
+ * NO VALUE OF ANY VARIABLE IS RECORDED HERE, and the original design's
174
+ * reason for that is untouched and right: a value comparison that WROTE the
175
+ * values would put credential material into a record an operator reads.
176
+ * `changedRedirections` carries NAMES only. The five redirection targets are
177
+ * harness-owned paths inside the task directory and carry no credential
178
+ * material, but recording only the names that differ is strictly less and is
179
+ * enough to act on.
123
180
  */
124
- export declare function readRegularFileIfPresent(path: string): RegularRead;
125
- export type TaskShape = "ship" | "scout";
126
- export type TaskStatus = "open" | "closed";
181
+ export interface CredentialHandoverRecord {
182
+ status: "compared" | "names-compared" | "pointers-compared" | "unreported" | "not-applicable";
183
+ /** Names the adapter reported that the kernel did not hand over. */
184
+ added: string[];
185
+ /** Names the kernel handed over that the adapter did not report. */
186
+ removed: string[];
187
+ /**
188
+ * Credential-store pointers whose value where the payload ran is not the
189
+ * harness-owned path the kernel handed over. Names only, never values.
190
+ * Empty when the pointers were compared and agreed, and also empty when
191
+ * there was no pointer evidence, which is why `status` and not this array
192
+ * is what says whether the comparison happened.
193
+ */
194
+ changedRedirections: string[];
195
+ /**
196
+ * WHICH ARTIFACT THE POINTER EVIDENCE WAS READ FROM, absent when there was
197
+ * none. It is a statement about a FILE, not a provenance the kernel
198
+ * verified, and the value used to say otherwise.
199
+ *
200
+ * turn-end-record read from tasks/<id>/turn-end. The kernel generates the
201
+ * hook that normally writes it, and for an adapter that
202
+ * honours the contract those values come from inside the
203
+ * child environment. THE FILE IS ALSO ADAPTER-REACHABLE:
204
+ * the adapter is handed `hookPath` and the record sits
205
+ * beside it, so an adapter that skips the hook and writes
206
+ * the record itself produces a record the kernel cannot
207
+ * tell from the hook's.
208
+ * adapter read from the adapter's launch outcome. The adapter's
209
+ * word about its own behaviour, and always was.
210
+ *
211
+ * THE VALUE WAS `child` UNTIL THE DR-0047 SWEEP FIX ROUND (CR-F-CRED-001,
212
+ * MEDIUM). Measured: an adapter that reverted HOME and XDG_CONFIG_HOME for
213
+ * its payload and wrote the turn-end record itself produced
214
+ * `{"status":"compared","changedRedirections":[],"redirectionSource":"child"}`
215
+ * while the payload could reach a real gh credential store. The word `child`
216
+ * asserted an observation no child had made. Neither value is evidence that
217
+ * the adapter was honest, and the record now says only what it can support.
218
+ * This is CR-B-001's own mechanism, a record whose status word is stronger
219
+ * than the check behind it, recurring one level up inside the same record;
220
+ * see src/hooks.ts for why a nonce does not close it and a rename does.
221
+ */
222
+ redirectionSource?: "turn-end-record" | "adapter";
223
+ }
224
+ /**
225
+ * THE CREDENTIAL DECISION FOR ONE SPAWN (M4-P8 step 6), recorded in the
226
+ * file the kernel already owns rather than in a new one across a new seam.
227
+ *
228
+ * The hazard this phase names is "a credential reaches a project payload
229
+ * and no artifact says so". Every arm of the decision is therefore written
230
+ * down even when it is the boring one: the payload class, whether the
231
+ * scrub ran, every widening with the reason it was granted, the handover
232
+ * comparison, and the refusal if one fired after this record existed.
233
+ */
234
+ export interface TaskCredentialRecord {
235
+ payloadClass: PayloadClass;
236
+ /**
237
+ * `scrubbed` when buildChildEnv constructed the environment, `inherited`
238
+ * when allowPrCredentials handed the parent's environment over unchanged.
239
+ *
240
+ * THIS FIELD RECORDS THE KERNEL'S DECISION AND ITS OWN CONSTRUCTION, NOT
241
+ * WHAT THE CHILD RECEIVED, and it is spelled out because it is the second
242
+ * member of CR-B-001's class found in this record (the derivation is in
243
+ * delivery/work-history/credential-route-fixes.md). "scrubbed" is a strong
244
+ * word: it says buildChildEnv ran, staged the five empty redirect targets
245
+ * and returned an environment, because a failure there is a rollback. It
246
+ * does NOT say the payload ran with that environment, because between this
247
+ * field being written and the payload starting there is an adapter. The
248
+ * field that speaks to what reached the child is `handover`, and it says
249
+ * which of its two properties it checked.
250
+ */
251
+ scrubMode: "scrubbed" | "inherited";
252
+ /** Every granted extension, in the order the caller declared them. */
253
+ extensions: CredentialExtensionRecord[];
254
+ /**
255
+ * ABSENT UNTIL THE LAUNCH HAS REPORTED, and the absence is a fact rather
256
+ * than an omission: this record is written BEFORE the payload starts,
257
+ * which is what makes a failure to write it safe to roll back, and at
258
+ * that moment no adapter has launched anything to compare. It is the same
259
+ * reason `ExecutorRecord` carries no resolved model.
260
+ */
261
+ handover?: CredentialHandoverRecord;
262
+ /**
263
+ * The refusal that fired AFTER this record was written, if one did.
264
+ *
265
+ * The step-5 refusal (the escape hatch asked for on a project payload) is
266
+ * deliberately absent from this field and cannot appear in it: that one
267
+ * refuses before the task directory exists, so there is no meta.json to
268
+ * record it in, and creating one would undo the property that a refused
269
+ * spawn creates nothing.
270
+ */
271
+ refusal?: string;
272
+ }
127
273
  export declare const TASK_SHAPES: readonly TaskShape[];
128
274
  /**
129
275
  * Task meta (the plan's field set, M1-P4 step 1).
@@ -146,6 +292,12 @@ export declare const TASK_SHAPES: readonly TaskShape[];
146
292
  * produced in M1-P3.
147
293
  * - status: open at spawn, closed by a successful teardown.
148
294
  * - createdAt: ISO-8601 timestamp of the spawn.
295
+ * - credentials: the M4-P8 credential decision for this spawn. OPTIONAL in
296
+ * the type, and the optionality is about READING rather than writing:
297
+ * every spawn from M4-P8 on writes it, and `readTaskMeta` deliberately
298
+ * does not require it, so a record written by an earlier kernel still
299
+ * reads instead of becoming an unreadable task whose worktree nobody can
300
+ * tear down. Consumers that need it check for it.
149
301
  */
150
302
  export interface TaskMeta {
151
303
  id: string;
@@ -157,6 +309,7 @@ export interface TaskMeta {
157
309
  baseOffline: boolean;
158
310
  status: TaskStatus;
159
311
  createdAt: string;
312
+ credentials?: TaskCredentialRecord;
160
313
  }
161
314
  export declare function taskDir(fleet: Fleet, taskId: string): string;
162
315
  export declare function metaPath(fleet: Fleet, taskId: string): string;
@@ -168,9 +321,45 @@ export declare function reportPath(fleet: Fleet, taskId: string): string;
168
321
  export declare function renderTaskMeta(meta: TaskMeta): string;
169
322
  export declare function writeTaskMeta(fleet: Fleet, meta: TaskMeta): void;
170
323
  /**
171
- * Read meta.json, or undefined when it is absent, is not a regular file,
172
- * or does not parse. All three mean the same thing to every caller: this
173
- * is not a readable record, and it is not evidence that the task finished.
324
+ * THE FOUR WAYS A TASK RECORD FAILS TO READ, KEPT APART (CR-F02, MEDIUM;
325
+ * T-036's mechanism).
326
+ *
327
+ * `absent` is a category that is empty BY OBSERVATION: the kernel looked and
328
+ * there is no record. Every other member is a category that is empty BY
329
+ * CONSTRUCTION: there IS something there and this read could not turn it into
330
+ * a record. Collapsing them makes "nothing here" and "something here I could
331
+ * not read" the same answer, and a task killed mid-write is the ordinary
332
+ * failure this repository keeps meeting, so the two are not the same answer at
333
+ * any caller that decides whether work is in flight.
334
+ */
335
+ export type TaskMetaRead =
336
+ /** A record that read and passed the field check. */
337
+ {
338
+ kind: "read";
339
+ meta: TaskMeta;
340
+ }
341
+ /** Nothing at this path. Empty by observation. */
342
+ | {
343
+ kind: "absent";
344
+ }
345
+ /** Present and not openable as a regular file, with the probe's reason. */
346
+ | {
347
+ kind: "unreadable";
348
+ reason: string;
349
+ }
350
+ /** Present, opened, and not JSON. The truncated-mid-write shape. */
351
+ | {
352
+ kind: "unparsable";
353
+ reason: string;
354
+ }
355
+ /** Present, parsed, and not a task record. Names the first bad field. */
356
+ | {
357
+ kind: "malformed";
358
+ reason: string;
359
+ };
360
+ /**
361
+ * THE ONE READ of a task record, and the one that says WHICH of the four
362
+ * outcomes happened.
174
363
  *
175
364
  * The type probe is INSIDE this function and not in front of one of its
176
365
  * callers (CR-520, CR-521). There is exactly one implementation of "read a
@@ -178,6 +367,29 @@ export declare function writeTaskMeta(fleet: Fleet, meta: TaskMeta): void;
178
367
  * cannot reopen the hole: src/teardown.ts reaches this directly, without
179
368
  * going through the liveness classifier, and a named pipe here used to
180
369
  * hang it forever.
370
+ *
371
+ * WHY THIS IS A SEPARATE FUNCTION FROM `readTaskMeta` RATHER THAN ITS NEW
372
+ * SIGNATURE, declared rather than left to be inferred. Widening the return
373
+ * type of `readTaskMeta` is the shape this repair wants, and it is a
374
+ * compile-time break in four modules that this fix round's file list does not
375
+ * own (src/pool.ts, src/liveness.ts, src/teardown.ts, src/commands/next.ts).
376
+ * The distinction is therefore made AVAILABLE upstream of all of them here,
377
+ * `readTaskMeta` is documented as the deliberate NARROWING of it, and the
378
+ * sites that still collapse are named in the fix round's work history rather
379
+ * than quietly left.
380
+ */
381
+ export declare function classifyTaskMeta(fleet: Fleet, taskId: string): TaskMetaRead;
382
+ /**
383
+ * Read meta.json, or undefined when it is absent, is not a regular file,
384
+ * does not parse, or parses and is not a task record.
385
+ *
386
+ * THIS IS A DELIBERATE NARROWING OF `classifyTaskMeta` AND THE COLLAPSE IS THE
387
+ * WHOLE OF CR-F02. `undefined` answers "is there a readable record" and it
388
+ * cannot answer "is there a task here", because it is returned both when the
389
+ * kernel looked and found nothing and when it found something it could not
390
+ * read. A caller that reports an absence, skips an entry, or decides nothing
391
+ * is in flight must call `classifyTaskMeta` instead; a caller that REFUSES on
392
+ * every one of the four (teardown does) loses nothing by using this.
181
393
  */
182
394
  export declare function readTaskMeta(fleet: Fleet, taskId: string): TaskMeta | undefined;
183
395
  /** Set meta.json status (teardown's last step; C-1's state authority). */
@@ -262,3 +474,31 @@ export type StepResult<T> = {
262
474
  * still decides whether to roll back, report a partial failure, or refuse.
263
475
  */
264
476
  export declare function runStep<T>(what: string, step: () => T): StepResult<T>;
477
+ /**
478
+ * runStep's shape for a step that returns a PROMISE (M4-P2 step 2).
479
+ *
480
+ * This is a PROMOTION, not a new function. It lived module-private in
481
+ * src/watcher.ts, where its own comment said "src/task.ts covers the sync
482
+ * one"; spawn's launch call site needs the same shape now that
483
+ * `ExecutorAdapter.launch` returns a promise, and T-005's one-mechanism
484
+ * rule makes copying it a third time the wrong answer. The promotion was
485
+ * measured before it was made: the two functions were run side by side over
486
+ * six `what`/thrown-error pairs covering an ordinary Error, an Error with an
487
+ * empty message, a thrown string, a thrown object with a custom toString, an
488
+ * Error subclass and a multi-line pair, and their reason strings were
489
+ * compared as buffers. All six were byte-identical, so no watcher message
490
+ * changes on any of those six. The capture is in the M4-P2 work history
491
+ * under `delivery/work-history/m4-p2.md`.
492
+ *
493
+ * WHY A SEPARATE FUNCTION RATHER THAN WIDENING runStep. `runStep` returns
494
+ * `{ok: true, value}` the instant its callback returns, and a callback that
495
+ * returns a promise returns one IMMEDIATELY. So `runStep` over an async
496
+ * step is not merely imprecise: its `ok: true` is a claim that the step
497
+ * SUCCEEDED when nothing has run yet, and a later rejection escapes the
498
+ * result type entirely as an unhandled rejection. That is the exact defect
499
+ * this phase's criterion 4 witness reddens against.
500
+ *
501
+ * It never swallows, on the same terms as the sync one: the caller gets a
502
+ * reason naming the step and still decides what to do about it.
503
+ */
504
+ export declare function runStepAsync<T>(what: string, step: () => Promise<T>): Promise<StepResult<T>>;