@tiphys/kernel 0.0.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 (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,597 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { EX_USAGE } from "../cli.js";
5
+ import { loadFleet } from "../fleet.js";
6
+ import { pathsIdentifySameObject } from "../path-identity.js";
7
+ import { TASK_ID_PATTERN, poolList } from "../pool.js";
8
+ import { readTaskMeta, singleLine } from "../task.js";
9
+ import { landedness } from "../teardown.js";
10
+ /**
11
+ * THE STOP CONDITION, COMPUTED RATHER THAN JUDGED (kernel plan M4, M4-P24
12
+ * criteria 1 to 4; M4-D-10 decided in delivery/plan/kernel-plan-m4.md:3211).
13
+ *
14
+ * WHY A COMMAND AND NOT A RULE. "The orchestrator does not decide when it is
15
+ * finished" has three recorded violations behind it, and every one of them was
16
+ * a JUDGMENT presented as a status report. A rule that depends on remembering
17
+ * does not survive a busy session; the answer this project keeps arriving at
18
+ * is a mechanism whose nonzero exit is a fact nobody can report their way
19
+ * around (CLAUDE.md, "The orchestrator does not decide when it is finished").
20
+ *
21
+ * THE EXIT CODE IS THREE-VALUED ON PURPOSE (criterion 1). `EXIT_WORK_REMAINS`
22
+ * is distinct from 0 AND from 1, so a caller can tell "work remains" from "the
23
+ * command failed". Collapsing them would make a crashing stop condition look
24
+ * exactly like a busy fleet, which is the T-008 shape one level up: a guard
25
+ * whose failure is indistinguishable from its red.
26
+ *
27
+ * WHAT CANNOT BE ESTABLISHED IS COUNTED AS WORK, NEVER AS EMPTY. A category
28
+ * this command failed to read is reported in `unknown` and holds the exit code
29
+ * at `EXIT_WORK_REMAINS`. T-036 is the measured instance of the opposite
30
+ * choice: a stop condition that silently treated an unreadable source as zero
31
+ * printed a finished milestone with six phases unbuilt
32
+ * (delivery/tuition/T-036-the-stop-condition-reported-a-finished-milestone.md:1).
33
+ *
34
+ * AND THAT PROPERTY HOLDS PER CANDIDATE, NOT ONLY PER CATEGORY (round 1). The
35
+ * first version of this file enforced it at CATEGORY granularity: the three
36
+ * `readdirSync` failures were recorded and every per-ROW drop inside a
37
+ * category was silent. The mechanism that reopens is this one sentence: A
38
+ * CATEGORY THAT IS EMPTY BY CONSTRUCTION IS REPORTED AS A CATEGORY THAT IS
39
+ * EMPTY BY OBSERVATION. Four measured instances, all of them exit 0 with
40
+ * `in flight: 0` and `unknown: 0`: a task whose meta.json did not read, a
41
+ * branch that exists only under `refs/remotes/`, a project clone reached
42
+ * through a symlink, and a clone whose `origin/HEAD` points at a feature
43
+ * branch. So every site below that removes a CANDIDATE from the walk either
44
+ * records it in `unknown` or carries a written reason why its absence is
45
+ * established rather than merely unobserved; the enumeration of all fourteen
46
+ * such sites is in delivery/work-history/m4-p24.md:1051.
47
+ *
48
+ * AND THE T-036 QUESTION IS ANSWERED IN THE CANNOT-SEE BLOCK RATHER THAN
49
+ * HIDDEN. Every in-flight category below derives from an artifact that exists
50
+ * only AFTER the work has started: a task record is written at dispatch, a
51
+ * pool record at pool create, a branch at branch create. So an empty report
52
+ * says "nothing is in flight", which is not the same sentence as "nothing is
53
+ * left to do", and `CANNOT_SEE` says so by name instead of leaving the reader
54
+ * to infer it. A fleet home has no artifact that names work before anyone
55
+ * starts it, so this is a reported limit and not a fixable one here.
56
+ *
57
+ * NO ABSOLUTE PATH LITERAL APPEARS IN THIS FILE (criterion 2). Every directory
58
+ * is derived from `loadFleet(process.cwd())` (src/fleet.ts:82). The script this
59
+ * command retires hard-codes one session's scratchpad path, which is why that
60
+ * is a criterion rather than a note.
61
+ *
62
+ * NO NETWORK (criterion 4). Nothing here fetches, and that is a property to
63
+ * keep rather than an omission: a stop condition that degrades to a shorter
64
+ * answer when the network is gone is indistinguishable from one reporting a
65
+ * quiet system, which is the shape CLAUDE.md standing warning 6 records for
66
+ * watchers. The cannot-see block and the exit code are therefore functions of
67
+ * the fleet home alone.
68
+ */
69
+ const USAGE = "usage: tiphys next";
70
+ /**
71
+ * Work remains. DISTINCT FROM 1, which is the command failing, and from 0,
72
+ * which is every in-flight category empty (criterion 1).
73
+ */
74
+ export const EXIT_WORK_REMAINS = 3;
75
+ /**
76
+ * WHAT THIS COMMAND CANNOT SEE, as a NAMED LIST (criterion 4).
77
+ *
78
+ * Printed unconditionally. It is not derived from a probe and it does not
79
+ * shorten when something is unreachable, because a list that shrinks when the
80
+ * network is gone tells the reader that fewer things are unknown at exactly
81
+ * the moment more of them are.
82
+ */
83
+ export const CANNOT_SEE = [
84
+ "open pull requests, and whether any of them is waiting on a review",
85
+ "CI conclusions on any head, on either the pull-request arm or the push arm",
86
+ "post-merge push runs on the new main head (T-009: a pull-request green is not evidence for the push arm)",
87
+ "work nobody has started: every in-flight category above is a trace that work leaves BEHIND, so an empty report cannot tell 'nothing left to do' from 'nothing started' (T-036)",
88
+ "whether a project's base ref is still its default branch: the base is read from the clone's own origin/HEAD, which git writes once at clone time and never refreshes, so a default-branch rename upstream leaves every existing clone pointing at a branch that is no longer the default until someone runs git remote set-head. The base actually used is printed under 'base refs' above, so the judgement can be checked rather than trusted",
89
+ ];
90
+ /** The heading the cannot-see block is printed under. */
91
+ export const CANNOT_SEE_HEADING = "cannot see (this command has no network; an absence here is not evidence of absence):";
92
+ /**
93
+ * One local git invocation in `cwd`. `-C` is how git is told where to
94
+ * resolve, which is NOT where this process is standing (standing warning 9),
95
+ * and `cwd` always arrives derived from the fleet home.
96
+ */
97
+ function runGit(cwd, args) {
98
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
99
+ return {
100
+ status: result.status ?? -1,
101
+ stdout: result.stdout ?? "",
102
+ stderr: result.stderr ?? "",
103
+ };
104
+ }
105
+ /**
106
+ * THE DELIVERED-ELSEWHERE PREDICATE (criterion 3).
107
+ *
108
+ * `git branch --merged` reports a squash-merged branch as UNMERGED, because
109
+ * the squash commit's sha is not the branch tip and the branch tip is not an
110
+ * ancestor of the base. This process squash-merges every phase, so under the
111
+ * naive implementation EVERY delivered branch reads as open forever and the
112
+ * stop condition never goes green.
113
+ *
114
+ * THE CITATION THAT STOOD HERE UNTIL ROUND 1 WAS WRONG, AND IT WAS WRONG IN
115
+ * THE SILENT DIRECTION. It read `delivery/STATE.md:54`, which resolves, which
116
+ * is why no gate reddened; the line it resolves to is about two merge blockers
117
+ * and says nothing about `git branch --merged`. Measured:
118
+ * `grep -n 'branch --merged' delivery/STATE.md` returns nothing, at this head
119
+ * and at the merge base, so the sentence is absent rather than moved. The
120
+ * claim itself is measured in this repository and the measurement is the
121
+ * citation now: witness/captures/next-delivered-elsewhere-git.txt records
122
+ * `git branch --merged main` listing neither the squash-merged branch nor the
123
+ * one delivered through another pull request. The same wrong citation is in
124
+ * the plan at delivery/plan/kernel-plan-m4.md:3239 and in this phase's work
125
+ * history; the work history is corrected, and the plan is an owner-approved
126
+ * document this phase does not edit, so it is raised rather than changed.
127
+ *
128
+ * THREE ARMS, AND THE THIRD IS WHY THIS IS NOT JUST A CALL TO `landedness`.
129
+ *
130
+ * 1. ANCESTOR. An ordinary merge or a fast-forward. `landedness` arm one.
131
+ * 2. CONTENT. Merging the branch into the base changes no tree, so the base
132
+ * already carries everything the branch did however it got there. This is
133
+ * MEMBER A, the squash merge, and it is `landedness` arm two.
134
+ * 3. PATCH EQUIVALENCE. Every commit the branch carries has an equivalent
135
+ * patch already upstream, found by patch id rather than by sha. This is
136
+ * MEMBER B: commits that landed inside ANOTHER branch's pull request, so
137
+ * they appear on the base interleaved with commits the branch never had.
138
+ *
139
+ * ARM 3 IS NOT REDUNDANT WITH ARM 2, and the case that separates them is the
140
+ * ordinary one. Once the base moves on and edits the same region again, a
141
+ * three-way merge of the delivered branch CONFLICTS, so arm 2 answers `open`
142
+ * for a branch whose every commit is demonstrably already upstream. Arm 3
143
+ * answers it by patch id, which survives the later edit.
144
+ *
145
+ * ARM 2 IS NOT REDUNDANT WITH ARM 3 EITHER. A squash collapses N commits into
146
+ * one, whose patch id is the id of the COMBINED diff and therefore equals no
147
+ * individual commit's. For any branch of more than one commit, arm 3 sees only
148
+ * unmatched commits and answers `open`.
149
+ *
150
+ * REUSE, NOT A SECOND IMPLEMENTATION (T-005). Arms 1 and 2 are
151
+ * `landedness` at src/teardown.ts:123, the predicate `tiphys teardown` already
152
+ * refuses on. A second copy here would be a second thing to keep true.
153
+ *
154
+ * INCONCLUSIVE IS NOT `open` AND IT IS NOT `delivered`. A git failure returns
155
+ * `unknown`, which the caller counts as work remaining, because the one answer
156
+ * this predicate must never invent is a confident one.
157
+ */
158
+ export function deliveredElsewhere(contextDir, branchRef, baseRef) {
159
+ const landed = landedness(contextDir, branchRef, baseRef);
160
+ if (landed.kind === "landed") {
161
+ return { kind: "delivered", how: landed.how };
162
+ }
163
+ if (landed.kind === "inconclusive") {
164
+ return { kind: "unknown", detail: landed.detail };
165
+ }
166
+ return patchEquivalence(contextDir, branchRef, baseRef);
167
+ }
168
+ /**
169
+ * Arm 3. `git cherry <base> <branch>` prints one line per commit the branch
170
+ * carries that the base does not carry BY SHA, marked `-` when an equivalent
171
+ * patch is already upstream and `+` when it is not. Every line `-` means the
172
+ * base already has every patch this branch introduces.
173
+ */
174
+ function patchEquivalence(contextDir, branchRef, baseRef) {
175
+ const cherry = runGit(contextDir, ["cherry", baseRef, branchRef]);
176
+ if (cherry.status !== 0) {
177
+ return {
178
+ kind: "unknown",
179
+ detail: `git cherry exited ${String(cherry.status)}: ${singleLine(cherry.stderr)}`,
180
+ };
181
+ }
182
+ const marks = cherry.stdout
183
+ .split("\n")
184
+ .map((line) => line.trim())
185
+ .filter((line) => line !== "");
186
+ if (marks.length === 0) {
187
+ // A branch with nothing ahead of the base is an ancestor of it, so arm 1
188
+ // should already have answered. Reaching here means the two readings
189
+ // disagree, which is reported rather than resolved by guessing.
190
+ return {
191
+ kind: "unknown",
192
+ detail: `git cherry reports no commit ahead of ${baseRef} while ` +
193
+ `merge-base --is-ancestor reports ${branchRef} is not an ancestor of it`,
194
+ };
195
+ }
196
+ return marks.every((line) => line.startsWith("-"))
197
+ ? { kind: "delivered", how: "patch-equivalent" }
198
+ : { kind: "open" };
199
+ }
200
+ /**
201
+ * The line a task whose record did not read is reported as. Named rather than
202
+ * inlined so that the one edit which reopens this defect, dropping the push,
203
+ * is a single contiguous statement a witness can mutate without disturbing
204
+ * anything else in the walk.
205
+ */
206
+ function unreadableTaskRecord(id) {
207
+ return (`task ${id}: tasks/${id}/meta.json did not read as a task record ` +
208
+ `(absent, not a regular file, not parseable, or missing a required ` +
209
+ `field: src/task.ts:381 returns the same value for all four), so this ` +
210
+ `task's status is not established and it is not evidence that the task ` +
211
+ `finished`);
212
+ }
213
+ /**
214
+ * Open tasks: `tasks/<id>/meta.json` carrying status `open`.
215
+ *
216
+ * THE UNREADABLE RECORD IS THE DANGEROUS ONE, NOT THE MISSING DIRECTORY. The
217
+ * first version of this walk recorded an item only when the record read AND
218
+ * said `open`, with no else arm, so a task directory whose meta.json did not
219
+ * read landed in neither `items` nor `unknown`. Measured before the fix, with
220
+ * one task directory and a truncated meta.json: `in flight: 0`, `unknown: 0`,
221
+ * `NOTHING IS IN FLIGHT`, exit 0. A process killed mid-write is the ordinary
222
+ * failure this repository keeps meeting, so that is T-036's own mechanism
223
+ * inside the command written to prevent T-036.
224
+ *
225
+ * FOUR CAUSES COLLAPSE INTO ONE VALUE AND THE MESSAGE SAYS SO. `readTaskMeta`
226
+ * (src/task.ts:381) returns `undefined` for an absent file, a file that is not
227
+ * regular or could not be read, a body that does not parse, and a body that
228
+ * parses and fails the field check. This walk does not re-read the file to
229
+ * tell them apart: a second reader of a task record is a second thing to keep
230
+ * true (T-005), and every one of the four means the same thing here, which is
231
+ * that the task's status is NOT ESTABLISHED. The reported line names all four
232
+ * rather than implying a diagnosis it did not make.
233
+ */
234
+ function openTasks(fleet, report) {
235
+ let entries;
236
+ try {
237
+ entries = readdirSync(fleet.tasksDir, { withFileTypes: true });
238
+ }
239
+ catch (error) {
240
+ report.unknown.push(`tasks/ could not be listed: ${singleLine(String(error))}`);
241
+ return;
242
+ }
243
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
244
+ const id = entry.name;
245
+ if (!TASK_ID_PATTERN.test(id)) {
246
+ // A DIRECTORY here is task-shaped storage whose name this command
247
+ // cannot parse, so it is reported rather than dropped. A FILE is not:
248
+ // `tiphys init` itself writes `tasks/.gitkeep`, and a fleet home that
249
+ // reported its own scaffolding as unknown could never exit 0. That is
250
+ // the line between an absence this walk established and one it merely
251
+ // did not look at.
252
+ if (entry.isDirectory()) {
253
+ report.unknown.push(`tasks/${id}: a directory under tasks/ whose name is not a task id, ` +
254
+ `so this command cannot read a task record from it and its status ` +
255
+ `is not established`);
256
+ }
257
+ continue;
258
+ }
259
+ const meta = readTaskMeta(fleet, id);
260
+ if (meta === undefined) {
261
+ report.unknown.push(unreadableTaskRecord(id));
262
+ continue;
263
+ }
264
+ if (meta.status === "open") {
265
+ report.items.push({ category: "task", what: `${id} (open)` });
266
+ }
267
+ }
268
+ }
269
+ /** Pool entries: a worktree, or an open task whose record did not survive. */
270
+ function poolEntries(fleet, report) {
271
+ let entries;
272
+ try {
273
+ entries = poolList(fleet);
274
+ }
275
+ catch (error) {
276
+ report.unknown.push(`the pool could not be listed: ${singleLine(String(error))}`);
277
+ return;
278
+ }
279
+ for (const entry of entries) {
280
+ report.items.push({
281
+ category: "worktree",
282
+ what: `${entry.taskId} (${entry.origin}, head ${entry.headSha})`,
283
+ });
284
+ }
285
+ }
286
+ /**
287
+ * The base ref a project clone's branches are judged against: whatever
288
+ * `origin/HEAD` points at, then `origin/main`, then a local `main`. A clone
289
+ * that answers none of them is UNKNOWN, never assumed, because a guessed base
290
+ * sends the delivered-elsewhere predicate at the wrong ref (src/pool.ts:369
291
+ * records the same refusal for the same reason).
292
+ *
293
+ * `origin/HEAD` CAN BE STALE AND THIS FUNCTION CANNOT TELL (round 1). Git
294
+ * writes it once at clone time and never refreshes it, so a default-branch
295
+ * rename upstream leaves it pointing at a branch that is no longer the
296
+ * default, and a clone made while a feature branch was checked out upstream
297
+ * points there from birth. Measured before round 1's fix: a clone whose
298
+ * `origin/HEAD` pointed at `feat-open` judged every branch against
299
+ * `feat-open`, found nothing open, and exited 0 while `git cherry
300
+ * refs/remotes/origin/main refs/remotes/origin/feat-open` printed a commit on
301
+ * no other ref.
302
+ *
303
+ * WHAT ROUND 1 DID AND DID NOT DO ABOUT IT, STATED EXACTLY, BECAUSE THE
304
+ * OBVIOUS FIX DOES NOT WORK AND WAS MEASURED NOT TO.
305
+ *
306
+ * It did not make this function RESOLVE the stale case. I did not find a
307
+ * signal inside a clone that establishes the upstream default branch: the
308
+ * refresh (`git remote set-head origin --auto`) is a fetch, and the local
309
+ * candidates I tried (`remote.origin.fetch`, `branch.<name>.remote`, a
310
+ * preference for `origin/main`) either say nothing about the default or
311
+ * replace an inherited guess with a typed one, which is the refusal
312
+ * src/pool.ts:369 argues against. Whether such a signal exists is an open
313
+ * question here, not a settled impossibility.
314
+ *
315
+ * AND THE BRANCH WALK DOES NOT RESCUE IT EITHER, WHICH I FOUND BY RUNNING IT.
316
+ * The reasoning that it would went: with `refs/remotes/` now walked, the real
317
+ * default branch is judged against the feature branch and comes back open.
318
+ * Measured against a clone whose `origin/HEAD` pointed at `feat-open`, with
319
+ * `feat-open` one commit AHEAD of `main`: `refs/remotes/origin/main` is an
320
+ * ANCESTOR of the base, so it is reported delivered, and the command exited 0
321
+ * with `in flight: 0`. The rescue only happens when the true default has
322
+ * DIVERGED from the mis-pointed base, which is one shape of the fault and not
323
+ * the common one.
324
+ *
325
+ * SO THE FIX IS DISCLOSURE PLUS A RECORDED DOUBT, AND BOTH ARE MECHANICAL.
326
+ * `how` is returned and printed, so the base a judgement used is in the
327
+ * output instead of being invisible. And when `origin/HEAD` points somewhere
328
+ * that is NEITHER `origin/main` NOR `origin/master` WHILE ONE OF THOSE TWO
329
+ * EXISTS IN THE SAME CLONE, two available signals DISAGREE, and the
330
+ * disagreement goes into `unknown`, which holds the exit at
331
+ * `EXIT_WORK_REMAINS`.
332
+ *
333
+ * THE DIFFERENCE BETWEEN THIS AND THE GUESS src/pool.ts:369 FORBIDS IS THE
334
+ * DIRECTION. That refusal is about FILLING an unresolved field from a
335
+ * plausible name, which produces a confident wrong answer. Nothing here is
336
+ * filled: the base stays whatever `origin/HEAD` said, and a name-shaped
337
+ * observation is used only to RAISE A DOUBT, whose worst case is a project
338
+ * whose genuine default is neither of those two names while a stale `main`
339
+ * survives beside it. That project is reported unknown and never exits 0
340
+ * until someone looks, which is a false positive in the one direction this
341
+ * command is built to fail in.
342
+ */
343
+ const CONVENTIONAL_DEFAULTS = ["refs/remotes/origin/main", "refs/remotes/origin/master"];
344
+ export function baseRefOf(projectDir) {
345
+ const head = runGit(projectDir, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
346
+ const pointed = head.stdout.trim();
347
+ if (head.status === 0 && pointed !== "") {
348
+ if (!CONVENTIONAL_DEFAULTS.includes(pointed)) {
349
+ for (const rival of CONVENTIONAL_DEFAULTS) {
350
+ const verified = runGit(projectDir, ["rev-parse", "--verify", "--quiet", rival]);
351
+ if (verified.status === 0 && verified.stdout.trim() !== "") {
352
+ return {
353
+ ok: true,
354
+ ref: pointed,
355
+ how: "origin/HEAD",
356
+ doubt: `origin/HEAD points at ${pointed} while ${rival} also exists in ` +
357
+ `this clone, so two signals disagree about which branch is the ` +
358
+ `default and every delivery judgement below rests on the one ` +
359
+ `git wrote at clone time and never refreshed. Settle it with ` +
360
+ `git remote set-head origin --auto, which needs the network ` +
361
+ `this command does not use`,
362
+ };
363
+ }
364
+ }
365
+ }
366
+ return { ok: true, ref: pointed, how: "origin/HEAD" };
367
+ }
368
+ for (const candidate of ["refs/remotes/origin/main", "refs/heads/main"]) {
369
+ const verified = runGit(projectDir, ["rev-parse", "--verify", "--quiet", candidate]);
370
+ if (verified.status === 0 && verified.stdout.trim() !== "") {
371
+ return { ok: true, ref: candidate, how: `${candidate} resolves` };
372
+ }
373
+ }
374
+ return {
375
+ ok: false,
376
+ reason: "neither origin/HEAD nor origin/main nor a local main resolves, so there is " +
377
+ "no base ref to judge delivery against",
378
+ };
379
+ }
380
+ /**
381
+ * Undelivered branches in every project clone under `projects/`, LOCAL AND
382
+ * REMOTE-TRACKING ALIKE.
383
+ *
384
+ * WHY REMOTE-TRACKING REFS ARE WALKED (round 1). The first version listed
385
+ * `refs/heads/` only. A clone is what `tiphys init` and `tiphys resume`
386
+ * produce, and in a fresh clone every branch except the checked-out one exists
387
+ * ONLY under `refs/remotes/`, so the normal state of a project was a branch
388
+ * category that was empty by construction and reported as empty by
389
+ * observation. Measured before the fix, against a clone of an upstream
390
+ * carrying one undelivered branch: `in flight: 0`, `unknown: 0`, `NOTHING IS
391
+ * IN FLIGHT`, exit 0.
392
+ *
393
+ * THREE EXCLUSIONS, AND EACH IS AN ESTABLISHED ABSENCE RATHER THAN A DROP.
394
+ *
395
+ * - THE BASE REF ITSELF. Judging the base against the base answers nothing.
396
+ * - A SYMBOLIC REF. `refs/remotes/origin/HEAD` is a POINTER at another ref
397
+ * in the same walk, so its target is judged on its own line and counting
398
+ * it twice would report one branch as two. Detected from
399
+ * `%(symref)` rather than by matching the name `HEAD`, because the name is
400
+ * a convention and the field is the fact.
401
+ * - A REF AT AN OBJECT ALREADY JUDGED. `refs/heads/feat` and
402
+ * `refs/remotes/origin/feat` at the same sha are one piece of work seen
403
+ * twice. Deduplicated by object name, which is what makes them the same
404
+ * commit; when the two disagree they have different object names and both
405
+ * are judged, which is correct, because a local branch ahead of its remote
406
+ * is a different in-flight state from the remote's.
407
+ */
408
+ function undeliveredBranches(fleet, report) {
409
+ let projects;
410
+ try {
411
+ // A SYMLINK IS A PROJECT AND `Dirent.isDirectory()` SAYS IT IS NOT. The
412
+ // dirent reflects an lstat, so a project clone reached through a symlink
413
+ // under `projects/` was filtered out of the walk entirely, and every
414
+ // branch in it, local or remote, was invisible. Measured before the fix:
415
+ // `projects/demo -> .../elsewhere/demo`, one undelivered branch inside,
416
+ // `in flight: 0`, `unknown: 0`, exit 0. `statSync` FOLLOWS, which is the
417
+ // question being asked, and an entry that resolves to no directory is
418
+ // reported rather than skipped: `projects/` is empty on a fresh fleet
419
+ // home (measured: `tiphys init` creates it with nothing in it), so
420
+ // nothing legitimate lands in `unknown` for it.
421
+ projects = readdirSync(fleet.projectsDir, { withFileTypes: true })
422
+ .map((entry) => entry.name)
423
+ .sort();
424
+ }
425
+ catch (error) {
426
+ report.unknown.push(`projects/ could not be listed: ${singleLine(String(error))}`);
427
+ return;
428
+ }
429
+ for (const project of projects) {
430
+ const dir = join(fleet.projectsDir, project);
431
+ let isDir;
432
+ try {
433
+ isDir = statSync(dir).isDirectory();
434
+ }
435
+ catch (error) {
436
+ report.unknown.push(`project ${project}: projects/${project} could not be resolved ` +
437
+ `(${singleLine(String(error))}), so no branch in it can be judged ` +
438
+ `delivered or open`);
439
+ continue;
440
+ }
441
+ if (!isDir) {
442
+ report.unknown.push(`project ${project}: projects/${project} does not resolve to a ` +
443
+ `directory, so no branch in it can be judged delivered or open`);
444
+ continue;
445
+ }
446
+ // THE DIRECTORY MUST BE ITS OWN REPOSITORY, AND THIS CHECK IS NOT
447
+ // CEREMONY. A fleet home IS a git repository, so `git -C projects/foo`
448
+ // inside a directory that is not a clone resolves against the FLEET's
449
+ // repository instead of failing: `refs/heads/main` verifies, the branch
450
+ // walk lists the fleet's own branches, and a project nobody could read is
451
+ // reported as having nothing in flight. Measured while writing this
452
+ // phase's own test, which is why it is a refusal rather than a comment.
453
+ // Compared with `pathsIdentifySameObject` because git canonicalises the
454
+ // path it prints and a string comparison answers no for two spellings of
455
+ // one directory (src/path-identity.ts:5).
456
+ const toplevel = runGit(dir, ["rev-parse", "--show-toplevel"]);
457
+ if (toplevel.status !== 0 || !pathsIdentifySameObject(toplevel.stdout.trim(), dir)) {
458
+ report.unknown.push(`project ${project}: not the top level of its own git repository ` +
459
+ `(git rev-parse --show-toplevel exited ${String(toplevel.status)} and ` +
460
+ `reported ${singleLine(toplevel.stdout) || "nothing"}), so no branch ` +
461
+ `in it can be judged delivered or open`);
462
+ continue;
463
+ }
464
+ const base = baseRefOf(dir);
465
+ if (!base.ok) {
466
+ report.unknown.push(`project ${project}: ${base.reason}`);
467
+ continue;
468
+ }
469
+ report.notes.push(`project ${project}: branches judged against ${base.ref} (chosen by ${base.how})`);
470
+ if (base.doubt !== undefined) {
471
+ report.unknown.push(`project ${project}: ${base.doubt}`);
472
+ }
473
+ const listed = runGit(dir, [
474
+ "for-each-ref",
475
+ "--format=%(objectname) %(refname) %(symref)",
476
+ "refs/heads/",
477
+ "refs/remotes/",
478
+ ]);
479
+ if (listed.status !== 0) {
480
+ report.unknown.push(`project ${project}: branches could not be listed: ${singleLine(listed.stderr)}`);
481
+ continue;
482
+ }
483
+ const judgedObjects = new Set();
484
+ for (const line of listed.stdout.split("\n")) {
485
+ const fields = line.trim().split(/\s+/);
486
+ const objectname = fields[0] ?? "";
487
+ const ref = fields[1] ?? "";
488
+ const symref = fields[2] ?? "";
489
+ if (objectname === "" || ref === "") {
490
+ continue;
491
+ }
492
+ if (symref !== "" || ref === base.ref || judgedObjects.has(objectname)) {
493
+ continue;
494
+ }
495
+ judgedObjects.add(objectname);
496
+ const delivery = deliveredElsewhere(dir, ref, base.ref);
497
+ if (delivery.kind === "open") {
498
+ report.items.push({ category: "branch", what: `${ref} in project ${project}` });
499
+ }
500
+ else if (delivery.kind === "unknown") {
501
+ report.unknown.push(`project ${project}: ${ref}: ${delivery.detail}`);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ /**
507
+ * Every in-flight category in a fleet home, in dependency order: the task
508
+ * record, the checkout it owns, and the branch that outlives both.
509
+ */
510
+ export function collectInFlight(fleet) {
511
+ const report = { items: [], unknown: [], notes: [] };
512
+ openTasks(fleet, report);
513
+ poolEntries(fleet, report);
514
+ undeliveredBranches(fleet, report);
515
+ return report;
516
+ }
517
+ /**
518
+ * THE ONE NEXT ACTION (criterion 1). Exactly one, chosen by the first
519
+ * non-empty category in the order above, so the answer is derived from the
520
+ * report rather than picked.
521
+ */
522
+ export function nextAction(report) {
523
+ const unread = report.unknown[0];
524
+ if (unread !== undefined) {
525
+ return `MEASURE the category this command could not read: ${unread}`;
526
+ }
527
+ const task = report.items.find((item) => item.category === "task");
528
+ if (task !== undefined) {
529
+ return `CLOSE OR REPORT task ${task.what}, the first open task in this fleet home`;
530
+ }
531
+ const worktree = report.items.find((item) => item.category === "worktree");
532
+ if (worktree !== undefined) {
533
+ return `TEAR DOWN ${worktree.what}, which still holds a checkout`;
534
+ }
535
+ const branch = report.items.find((item) => item.category === "branch");
536
+ if (branch !== undefined) {
537
+ return `DELIVER OR RETIRE ${branch.what}, whose commits are not on its base by any route`;
538
+ }
539
+ return ("NOTHING IS IN FLIGHT in this fleet home. That is not the same sentence as " +
540
+ "'the work is done'; read the cannot-see list above before concluding it");
541
+ }
542
+ /** The cannot-see block. Unconditional, and it probes nothing to build. */
543
+ export function cannotSeeBlock() {
544
+ const lines = [CANNOT_SEE_HEADING];
545
+ for (const item of CANNOT_SEE) {
546
+ lines.push(` - ${item}`);
547
+ }
548
+ return lines;
549
+ }
550
+ /** The whole report, as lines, with exactly one `next action:` line. */
551
+ export function renderReport(fleet, report) {
552
+ const lines = [`fleet ${fleet.root}`];
553
+ lines.push(`in flight: ${String(report.items.length)}`);
554
+ for (const item of report.items) {
555
+ lines.push(` ${item.category} ${item.what}`);
556
+ }
557
+ lines.push(`unknown: ${String(report.unknown.length)}`);
558
+ for (const detail of report.unknown) {
559
+ lines.push(` ${detail}`);
560
+ }
561
+ // DISCLOSURES, NOT WORK. Printed between the two counted categories and the
562
+ // cannot-see block because that is where a reader decides whether to believe
563
+ // the counts: the base ref a project's branches were judged against is the
564
+ // input the delivered-elsewhere predicate cannot check for itself.
565
+ lines.push(`base refs: ${String(report.notes.length)}`);
566
+ for (const note of report.notes) {
567
+ lines.push(` ${note}`);
568
+ }
569
+ lines.push(...cannotSeeBlock());
570
+ lines.push(`next action: ${nextAction(report)}`);
571
+ return lines;
572
+ }
573
+ /** Work remains whenever anything is in flight OR anything could not be read. */
574
+ export function exitCodeFor(report) {
575
+ return report.items.length + report.unknown.length > 0 ? EXIT_WORK_REMAINS : 0;
576
+ }
577
+ export function cmdNext(args) {
578
+ if (args.length > 0) {
579
+ process.stderr.write(`${USAGE}\n`);
580
+ return EX_USAGE;
581
+ }
582
+ let fleet;
583
+ try {
584
+ fleet = loadFleet(process.cwd());
585
+ }
586
+ catch (error) {
587
+ process.stderr.write(`tiphys next: ${singleLine(error.message)}\n`);
588
+ return 1;
589
+ }
590
+ const report = collectInFlight(fleet);
591
+ const code = exitCodeFor(report);
592
+ const lines = renderReport(fleet, report);
593
+ lines.push(`exit ${String(code)} (0 means every in-flight category is empty, ` +
594
+ `${String(EXIT_WORK_REMAINS)} means work remains, 1 means this command failed)`);
595
+ process.stdout.write(`${lines.join("\n")}\n`);
596
+ return code;
597
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `tiphys plan project --phase-id <id> [--plan <file>] [--out <dir>]`
3
+ * (kernel plan M3, M3-P1 step 2; D-M3-18).
4
+ *
5
+ * Emits the M2-P4 scope auditor's phase declaration from the plan, so the
6
+ * auditor's input becomes a GENERATED VIEW of one source instead of a second
7
+ * hand-authored source that can drift.
8
+ *
9
+ * The output path is `<out>/<phase-id-lowercased>.json`, defaulting to
10
+ * `delivery/plan/phase-declarations`, which is the directory
11
+ * `gates.manifest.json` passes to the auditor as `--declarations`.
12
+ *
13
+ * With no `--out` the document is written; with `--stdout` it is printed and
14
+ * nothing is written, which is what a test or a reviewer wants when checking
15
+ * WHAT would be emitted without touching the tree.
16
+ *
17
+ * Every path this command touches is operator-supplied and is classified
18
+ * before it is opened or written (D-M3-27).
19
+ */
20
+ export declare function cmdPlan(argv: string[]): number;