@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
@@ -0,0 +1,88 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { EX_USAGE } from "../cli.js";
4
+ import { EPHEMERAL_DIRS, classifyLayoutEntry, missingDurableEntries, } from "../fleet.js";
5
+ /**
6
+ * The plan, or the one reason it cannot be made. Deciding every entry BEFORE
7
+ * creating any of them is what makes criterion 2's "creates nothing" true of
8
+ * the refusal arms: a refusal discovered halfway through a creating loop has
9
+ * already changed the tree.
10
+ */
11
+ function planRebuild(root) {
12
+ const steps = [];
13
+ for (const name of EPHEMERAL_DIRS) {
14
+ const entry = classifyLayoutEntry(join(root, name));
15
+ if (entry.kind === "directory") {
16
+ steps.push({ name, rebuild: false });
17
+ continue;
18
+ }
19
+ if (entry.kind === "absent") {
20
+ steps.push({ name, rebuild: true });
21
+ continue;
22
+ }
23
+ return {
24
+ refusal: `tiphys resume: ${root} cannot be rehydrated: ${entry.reason}, ` +
25
+ `and resume never removes anything to make room`,
26
+ };
27
+ }
28
+ return { steps };
29
+ }
30
+ export function cmdResume(args) {
31
+ const [dir, ...extra] = args;
32
+ if (extra.length > 0) {
33
+ process.stderr.write("usage: tiphys resume [dir]\n");
34
+ return EX_USAGE;
35
+ }
36
+ const root = resolve(dir ?? ".");
37
+ const rootEntry = classifyLayoutEntry(root);
38
+ if (rootEntry.kind !== "directory") {
39
+ process.stderr.write(`tiphys resume: ${root} is not a directory, so there is no fleet home here to rebuild\n`);
40
+ return 1;
41
+ }
42
+ /* THE FIRST PRECONDITION: a fleet home is a git repository, and a clone of
43
+ one is what this command exists to repair. A directory with no `.git` is
44
+ not a fleet clone whatever else it holds, and creating three directories
45
+ inside it would be a success report about a fleet that does not exist. */
46
+ const gitEntry = classifyLayoutEntry(join(root, ".git"));
47
+ if (gitEntry.kind === "absent") {
48
+ process.stderr.write(`tiphys resume: ${root} is not a git repository, .git is absent, ` +
49
+ `so it is not a cloned fleet home; run tiphys init <dir> to create one\n`);
50
+ return 1;
51
+ }
52
+ if (gitEntry.kind === "unexaminable") {
53
+ process.stderr.write(`tiphys resume: ${gitEntry.reason}\n`);
54
+ return 1;
55
+ }
56
+ /* THE SECOND PRECONDITION, and it is a different failure from the first.
57
+ A git repository that is not a fleet home (any other clone, including the
58
+ kernel's own) passes the `.git` test. Rebuilding the ephemeral three
59
+ inside it would litter a stranger's repository and report success. The
60
+ durable entries are what a clone of a fleet home carries, so their absence
61
+ means either "not a fleet home" or "a fleet home that has lost durable
62
+ content", and resume fabricates NEITHER: durable content comes back from
63
+ the remote, never from this command. */
64
+ const missingDurable = missingDurableEntries(root);
65
+ if (missingDurable.length > 0) {
66
+ process.stderr.write(`tiphys resume: ${root} is a git repository but not a fleet home, ` +
67
+ `missing ${missingDurable.join(", ")}; durable content is restored by ` +
68
+ `fetching from the remote, never by resume\n`);
69
+ return 1;
70
+ }
71
+ const planned = planRebuild(root);
72
+ if ("refusal" in planned) {
73
+ process.stderr.write(`${planned.refusal}\n`);
74
+ return 1;
75
+ }
76
+ /* PER ENTRY, never all-or-nothing. An interrupted resume leaves a
77
+ half-rebuilt layout, and that half is the common case rather than an edge:
78
+ rebuilding the set because one member is missing is how a live worktree
79
+ that survived the reclaim gets reported as rebuilt when it was not. */
80
+ for (const step of planned.steps) {
81
+ if (!step.rebuild) {
82
+ continue;
83
+ }
84
+ mkdirSync(join(root, step.name));
85
+ process.stdout.write(`REBUILT ${step.name}/\n`);
86
+ }
87
+ return 0;
88
+ }
@@ -6,7 +6,9 @@ import { singleLine } from "../task.js";
6
6
  /**
7
7
  * tiphys spawn --task <id> --project <path> --brief <file> --shape
8
8
  * ship|scout --exec <cmd> [--deadline <seconds>] [--offline]
9
- * (kernel plan v1, M1-P4 step 4). Runs in a fleet home (cwd).
9
+ * [--role <name>] [--tier <name>] [--phase <id>] [--adapter <specifier>]
10
+ * (kernel plan v1, M1-P4 step 4; --role, --tier and --phase added by
11
+ * M4-P3 and --adapter by M4-P4). Runs in a fleet home (cwd).
10
12
  *
11
13
  * --exec is REQUIRED in M1: spawn without it exits 64 with usage,
12
14
  * because the multiplexer-window adapter that would make an exec-less
@@ -22,7 +24,8 @@ import { singleLine } from "../task.js";
22
24
  * worktree a task's meta describes.
23
25
  */
24
26
  const USAGE = "usage: tiphys spawn --task <id> --project <path> --brief <file> " +
25
- "--shape ship|scout --exec <cmd> [--deadline <seconds>] [--offline]";
27
+ "--shape ship|scout --exec <cmd> [--deadline <seconds>] [--offline] " +
28
+ "[--role <name>] [--tier <name>] [--phase <id>] [--adapter <specifier>]";
26
29
  function usageError(message) {
27
30
  if (message !== undefined) {
28
31
  process.stderr.write(`tiphys spawn: ${message}\n`);
@@ -39,6 +42,10 @@ function parseFlags(args) {
39
42
  exec: undefined,
40
43
  deadlineSeconds: undefined,
41
44
  offline: false,
45
+ role: undefined,
46
+ tier: undefined,
47
+ phase: undefined,
48
+ adapter: undefined,
42
49
  };
43
50
  for (let i = 0; i < args.length; i += 1) {
44
51
  const flag = args[i];
@@ -82,6 +89,34 @@ function parseFlags(args) {
82
89
  parsed.deadlineSeconds = seconds;
83
90
  i += 1;
84
91
  }
92
+ else if (flag === "--role" && value !== undefined) {
93
+ parsed.role = value;
94
+ i += 1;
95
+ }
96
+ else if (flag === "--tier" && value !== undefined) {
97
+ // VERBATIM, and deliberately unvalidated: this is the DECLARED TIER,
98
+ // whatever role-model-config.yaml declares, and the tier-to-model
99
+ // mapping lives in the plugin. A kernel that checked this value
100
+ // against a list would be holding the vocabulary the plugin owns.
101
+ parsed.tier = value;
102
+ i += 1;
103
+ }
104
+ else if (flag === "--phase" && value !== undefined) {
105
+ // CARRIED, never derived from the branch name (M4-D-22 is open).
106
+ parsed.phase = value;
107
+ i += 1;
108
+ }
109
+ else if (flag === "--adapter" && value !== undefined) {
110
+ // VERBATIM, and it is a MODULE SPECIFIER rather than a path: the
111
+ // kernel resolves it with Node module resolution rooted at the fleet
112
+ // home (src/adapters/load.ts), which is the one root that is
113
+ // owner-controlled. Nothing is checked here, because a check here
114
+ // would run against this process's own resolution root, which is the
115
+ // kernel checkout, and that is precisely the root this phase exists to
116
+ // keep out of the answer.
117
+ parsed.adapter = value;
118
+ i += 1;
119
+ }
85
120
  else if (flag === "--offline") {
86
121
  parsed.offline = true;
87
122
  }
@@ -128,6 +163,20 @@ export async function cmdSpawn(args) {
128
163
  exec: flags.exec,
129
164
  deadlineSeconds: flags.deadlineSeconds,
130
165
  offline: flags.offline,
166
+ /*
167
+ * PROJECT, ALWAYS, AND THE CLI HAS NO FLAG TO SAY OTHERWISE (M4-P8
168
+ * step 2). `tiphys spawn` launches a payload into a project worktree,
169
+ * and the declared credential escape hatch is not reachable from this
170
+ * command at all: `allowPrCredentials` is a library-seam field with no
171
+ * flag behind it, which is exactly why the orchestrator class is not
172
+ * offered here. An operator who could type --payload-class orchestrator
173
+ * would be able to ask for the pairing spawnTask exists to refuse.
174
+ */
175
+ payloadClass: "project",
176
+ role: flags.role,
177
+ declaredTier: flags.tier,
178
+ phaseId: flags.phase,
179
+ adapterSpecifier: flags.adapter,
131
180
  });
132
181
  if (!result.ok) {
133
182
  // One reason line, structurally (CR-303): a reason may carry a
@@ -7,10 +7,12 @@
7
7
  *
8
8
  * Runs in a fleet home (cwd). `emit` composes a record, VALIDATES it against
9
9
  * the shipped `status-line` schema before writing anything, appends one line
10
- * to `state/status/stream.jsonl`, then rewrites `state/status/current.json`
11
- * atomically. `show` reads `current.json` ONLY and never opens the stream
12
- * (constraint C-1: never read current state from the tail of an append-only
13
- * log).
10
+ * to `state/status/stream.jsonl`, then rewrites the DURABLE
11
+ * `status/current.json` atomically. `show` reads `current.json` ONLY and
12
+ * never opens the stream (constraint C-1: never read current state from the
13
+ * tail of an append-only log). The two documents sit on opposite sides of the
14
+ * fleet `.gitignore` since M4-D-13, which is why the paths differ in prefix;
15
+ * src/status.ts carries the reasoning.
14
16
  *
15
17
  * The state vocabulary is closed and is enforced, not requested: `--state
16
18
  * progress` is refused naming the permitted values, because R-084's
@@ -7,10 +7,12 @@
7
7
  *
8
8
  * Runs in a fleet home (cwd). `emit` composes a record, VALIDATES it against
9
9
  * the shipped `status-line` schema before writing anything, appends one line
10
- * to `state/status/stream.jsonl`, then rewrites `state/status/current.json`
11
- * atomically. `show` reads `current.json` ONLY and never opens the stream
12
- * (constraint C-1: never read current state from the tail of an append-only
13
- * log).
10
+ * to `state/status/stream.jsonl`, then rewrites the DURABLE
11
+ * `status/current.json` atomically. `show` reads `current.json` ONLY and
12
+ * never opens the stream (constraint C-1: never read current state from the
13
+ * tail of an append-only log). The two documents sit on opposite sides of the
14
+ * fleet `.gitignore` since M4-D-13, which is why the paths differ in prefix;
15
+ * src/status.ts carries the reasoning.
14
16
  *
15
17
  * The state vocabulary is closed and is enforced, not requested: `--state
16
18
  * progress` is refused naming the permitted values, because R-084's
@@ -0,0 +1,47 @@
1
+ /** The remote a fleet home pushes to unless told otherwise. */
2
+ export declare const DEFAULT_REMOTE = "origin";
3
+ /**
4
+ * The commit subject, and it carries NO PATHS ON PURPOSE (criterion 6,
5
+ * CLAUDE.md binding convention 7: commit messages carry no AI model or tool
6
+ * names). A message composed from the paths it commits inherits whatever
7
+ * those paths are named, and a fleet's decision records and task directories
8
+ * are named after the things they decide, which routinely includes the
9
+ * harness or the model a decision is about. A count cannot carry a name.
10
+ */
11
+ export declare function syncCommitMessage(count: number): string;
12
+ /** One path git reports as changed, with its two status columns. */
13
+ export interface ChangedPath {
14
+ path: string;
15
+ /** The index column (X). A space means unstaged; `?` means untracked. */
16
+ index: string;
17
+ /** The worktree column (Y). */
18
+ worktree: string;
19
+ }
20
+ /**
21
+ * Parse `git status --porcelain=v1 -z --untracked-files=all`.
22
+ *
23
+ * The NUL form is the only safe one: a path holding a space, a quote or a
24
+ * newline is printed raw here and is C-quoted in the newline form, so the
25
+ * newline form would need an unquoting pass that is its own defect surface.
26
+ * A rename or copy record carries TWO paths, the new one first and the
27
+ * original second, and both are returned: a rename out of the durable half
28
+ * into the ephemeral half must be visible as a change to both names.
29
+ */
30
+ export declare function parsePorcelainStatus(payload: string): ChangedPath[];
31
+ /** The `.gitignore` rule that makes one path ephemeral. */
32
+ export interface IgnoreRule {
33
+ source: string;
34
+ line: string;
35
+ pattern: string;
36
+ }
37
+ /**
38
+ * Parse `git check-ignore --no-index -v -z --stdin`, whose output is a flat
39
+ * NUL-separated stream of four fields per MATCHING path: source, line
40
+ * number, pattern, pathname. Paths that match no rule are absent from the
41
+ * output entirely, which is what makes the result a set of the ephemeral
42
+ * ones rather than a verdict per input.
43
+ */
44
+ export declare function parseCheckIgnore(payload: string): Map<string, IgnoreRule>;
45
+ /** How an ignore rule is named in every line this command prints. */
46
+ export declare function renderRule(rule: IgnoreRule): string;
47
+ export declare function cmdSync(argv: string[]): number;
@@ -0,0 +1,341 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { EX_USAGE } from "../cli.js";
3
+ import { FLEET_SCRATCH_SUFFIXES, isFleetScratchPath, loadFleet } from "../fleet.js";
4
+ import { MACHINE_IDENTITY_EMAIL, MACHINE_IDENTITY_NAME } from "./init.js";
5
+ /**
6
+ * `tiphys sync`: commit the durable half of a fleet home and push it
7
+ * (kernel plan M4, M4-P18).
8
+ *
9
+ * WHAT THIS IS THE EXECUTABLE HALF OF. AGENTS.md's
10
+ * `fleet-state-commit-discipline` clause says durable state is committed and
11
+ * pushed at the moment it changes, and that the PUSH is the half that gets
12
+ * dropped. Until this phase the whole clause was discharged by an agent
13
+ * remembering to run git, which is the shape this repository has recorded
14
+ * three times: a rule that depends on remembering does not survive a busy
15
+ * session, and the answer is a mechanism.
16
+ *
17
+ * THE DURABLE SET IS DERIVED, NEVER LISTED HERE (criterion 2). A second list
18
+ * of ephemeral prefixes inside this file would be a second thing to keep in
19
+ * step with the fleet's own `.gitignore`, and the first divergence would be
20
+ * silent: a prefix added to the ignore set would keep being committed by a
21
+ * command that had never heard of it. So the question "is this path
22
+ * ephemeral" is answered by GIT, against the fleet `.gitignore` that
23
+ * `tiphys init` wrote, and this module holds no prefix list at all.
24
+ *
25
+ * `--no-index` IS THE LOAD-BEARING FLAG AND THE REASON IS THE HAZARD ITSELF.
26
+ * `git check-ignore` without it answers "would git ignore this path", and a
27
+ * TRACKED file is never ignored whatever `.gitignore` says, so it answers 1
28
+ * for exactly the file this command must exclude. The dangerous state is a
29
+ * lease or a scratch file that got tracked once (by a `git add -f`, or by
30
+ * being committed before the prefix was ignored): from then on every `git
31
+ * add -A` re-commits it, and a fleet's lease travels through the remote to
32
+ * environments that must rebuild it rather than restore it. With
33
+ * `--no-index` the answer is about the RULES, which is the question being
34
+ * asked. Measured contract in witness/captures/m4-p18-git-contracts.txt.
35
+ *
36
+ * WHY A STAGED EPHEMERAL PATH IS A REFUSAL AND NOT A SKIP (criterion 4).
37
+ * This command stages the durable paths it enumerated and then commits, and
38
+ * `git commit` commits the INDEX, not the pathspec it was handed. An
39
+ * operator who ran `git add -A` first has already put the lease in the
40
+ * index, so committing at all would commit it. Unstaging on the operator's
41
+ * behalf is a destructive act on work this command did not create, so the
42
+ * refusal names the path and the rule that makes it ephemeral, and commits
43
+ * nothing.
44
+ *
45
+ * SUBSTRATE-NEUTRAL (DR-0007) and C-2 clean: pure git and filesystem, no
46
+ * process probing, no pid, no signal.
47
+ */
48
+ const USAGE = "usage: tiphys sync [--remote <name>]";
49
+ /** The remote a fleet home pushes to unless told otherwise. */
50
+ export const DEFAULT_REMOTE = "origin";
51
+ /**
52
+ * The commit subject, and it carries NO PATHS ON PURPOSE (criterion 6,
53
+ * CLAUDE.md binding convention 7: commit messages carry no AI model or tool
54
+ * names). A message composed from the paths it commits inherits whatever
55
+ * those paths are named, and a fleet's decision records and task directories
56
+ * are named after the things they decide, which routinely includes the
57
+ * harness or the model a decision is about. A count cannot carry a name.
58
+ */
59
+ export function syncCommitMessage(count) {
60
+ return `tiphys sync: ${String(count)} durable path(s)`;
61
+ }
62
+ function runGit(root, args, options = {}) {
63
+ const run = spawnSync("git", ["-C", root, ...args], {
64
+ encoding: "utf8",
65
+ input: options.input,
66
+ env: options.extraEnv === undefined
67
+ ? process.env
68
+ : { ...process.env, ...options.extraEnv },
69
+ });
70
+ return {
71
+ status: run.status,
72
+ stdout: run.stdout ?? "",
73
+ stderr: run.stderr ?? "",
74
+ };
75
+ }
76
+ /**
77
+ * Parse `git status --porcelain=v1 -z --untracked-files=all`.
78
+ *
79
+ * The NUL form is the only safe one: a path holding a space, a quote or a
80
+ * newline is printed raw here and is C-quoted in the newline form, so the
81
+ * newline form would need an unquoting pass that is its own defect surface.
82
+ * A rename or copy record carries TWO paths, the new one first and the
83
+ * original second, and both are returned: a rename out of the durable half
84
+ * into the ephemeral half must be visible as a change to both names.
85
+ */
86
+ export function parsePorcelainStatus(payload) {
87
+ const fields = payload.split("\0");
88
+ const found = [];
89
+ for (let index = 0; index < fields.length; index += 1) {
90
+ const record = fields[index];
91
+ if (record === "") {
92
+ continue;
93
+ }
94
+ const x = record.slice(0, 1);
95
+ const y = record.slice(1, 2);
96
+ const path = record.slice(3);
97
+ found.push({ path, index: x, worktree: y });
98
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
99
+ const original = fields[index + 1];
100
+ index += 1;
101
+ if (original !== undefined && original !== "") {
102
+ found.push({ path: original, index: x, worktree: y });
103
+ }
104
+ }
105
+ }
106
+ return found;
107
+ }
108
+ /**
109
+ * Parse `git check-ignore --no-index -v -z --stdin`, whose output is a flat
110
+ * NUL-separated stream of four fields per MATCHING path: source, line
111
+ * number, pattern, pathname. Paths that match no rule are absent from the
112
+ * output entirely, which is what makes the result a set of the ephemeral
113
+ * ones rather than a verdict per input.
114
+ */
115
+ export function parseCheckIgnore(payload) {
116
+ const fields = payload.split("\0");
117
+ const found = new Map();
118
+ for (let index = 0; index + 3 < fields.length; index += 4) {
119
+ const source = fields[index];
120
+ const line = fields[index + 1];
121
+ const pattern = fields[index + 2];
122
+ const path = fields[index + 3];
123
+ if (path === "") {
124
+ continue;
125
+ }
126
+ found.set(path, { source, line, pattern });
127
+ }
128
+ return found;
129
+ }
130
+ /** How an ignore rule is named in every line this command prints. */
131
+ export function renderRule(rule) {
132
+ return `${rule.source}:${rule.line} ${rule.pattern}`;
133
+ }
134
+ /**
135
+ * Ask git which of these paths the fleet `.gitignore` covers.
136
+ *
137
+ * THE THREE EXIT CODES ARE NOT TWO. 0 means at least one path matched, 1
138
+ * means none did, and 128 means git could not answer at all. Folding 128
139
+ * into "nothing is ephemeral" is how a command reports a clean sync while
140
+ * committing a lease, so it is a refusal here and the exit code is named.
141
+ */
142
+ function classify(root, paths) {
143
+ if (paths.length === 0) {
144
+ return { ok: true, ephemeral: new Map() };
145
+ }
146
+ const run = runGit(root, ["check-ignore", "--no-index", "-v", "-z", "--stdin"], {
147
+ input: `${paths.join("\0")}\0`,
148
+ });
149
+ if (run.status === 0) {
150
+ return { ok: true, ephemeral: parseCheckIgnore(run.stdout) };
151
+ }
152
+ if (run.status === 1) {
153
+ return { ok: true, ephemeral: new Map() };
154
+ }
155
+ return {
156
+ ok: false,
157
+ reason: `git check-ignore exited ${String(run.status)} and could not say which paths are ephemeral, ` +
158
+ `so nothing was committed: ${run.stderr.split("\n")[0] ?? ""}`,
159
+ };
160
+ }
161
+ function parseArgs(argv) {
162
+ let remote = DEFAULT_REMOTE;
163
+ for (let index = 0; index < argv.length; index += 1) {
164
+ const flag = argv[index];
165
+ if (flag !== "--remote") {
166
+ return { usageError: `unknown option ${flag}` };
167
+ }
168
+ const value = argv[index + 1];
169
+ if (value === undefined) {
170
+ return { usageError: "--remote requires a value" };
171
+ }
172
+ remote = value;
173
+ index += 1;
174
+ }
175
+ return { remote };
176
+ }
177
+ export function cmdSync(argv) {
178
+ const parsed = parseArgs(argv);
179
+ if (parsed.remote === undefined) {
180
+ process.stderr.write(`tiphys sync: ${parsed.usageError ?? "usage error"}\n${USAGE}\n`);
181
+ return EX_USAGE;
182
+ }
183
+ const remote = parsed.remote;
184
+ const fleet = loadFleet(process.cwd());
185
+ /* THE REMOTE IS ESTABLISHED BEFORE ANYTHING IS COMMITTED. The discipline
186
+ is commit AND push; a sync that commits and then discovers there is
187
+ nowhere to push has done the half that gets dropped and reported the
188
+ half that does not. */
189
+ const remotes = runGit(fleet.root, ["remote"]);
190
+ if (remotes.status !== 0) {
191
+ process.stderr.write(`tiphys sync: git remote failed in ${fleet.root}: ${remotes.stderr.split("\n")[0] ?? ""}\n`);
192
+ return 1;
193
+ }
194
+ const known = remotes.stdout.split("\n").map((line) => line.trim());
195
+ if (!known.includes(remote)) {
196
+ process.stderr.write(`tiphys sync: ${fleet.root} has no remote named ${remote}, so durable state cannot be pushed; ` +
197
+ `add one with git remote add ${remote} <url>, nothing was committed\n`);
198
+ return 1;
199
+ }
200
+ const status = runGit(fleet.root, [
201
+ "status",
202
+ "--porcelain=v1",
203
+ "-z",
204
+ "--untracked-files=all",
205
+ ]);
206
+ if (status.status !== 0) {
207
+ process.stderr.write(`tiphys sync: git status failed in ${fleet.root}: ${status.stderr.split("\n")[0] ?? ""}\n`);
208
+ return 1;
209
+ }
210
+ const changed = parsePorcelainStatus(status.stdout);
211
+ const classified = classify(fleet.root, [...new Set(changed.map((entry) => entry.path))]);
212
+ if (!classified.ok) {
213
+ process.stderr.write(`tiphys sync: ${classified.reason}\n`);
214
+ return 1;
215
+ }
216
+ const ephemeral = classified.ephemeral;
217
+ /* THE SCRATCH REFUSAL RUNS BEFORE THE STAGED-EPHEMERAL ONE, and the order
218
+ is stated because it is a choice. A fleet carrying both a stray scratch
219
+ file and a staged ephemeral path reports the scratch file and stops; both
220
+ arms refuse and commit nothing, so no path is committed either way, and
221
+ the operator sees one refusal at a time rather than two. It also keeps the
222
+ staged-ephemeral block the last `return 1` before `const durable`, which
223
+ is where witness/sync-staged-lease-refused.json aims its second member. */
224
+ /* THE DENYLIST IS THREE DIRECTORY PREFIXES AND THE QUESTION IT ANSWERS IS
225
+ NARROWER THAN THE ONE THIS COMMAND ASKS.
226
+
227
+ `git check-ignore --no-index` answers "is this path covered by a rule
228
+ someone wrote down". This command reads that as "is this path meant to
229
+ be committed". Those are the same question only for paths someone
230
+ thought to declare, and the fleet ignore set (src/fleet.ts:29) declares
231
+ three directory prefixes and nothing else. Anything the kernel writes
232
+ outside them is DURABLE BY DEFAULT.
233
+
234
+ Two paths the kernel itself creates land there, and neither is fleet
235
+ content: the atomic-rename scratch file src/status.ts:142 writes beside
236
+ its TRACKED target, and the one src/cutover.ts:278 writes at the fleet
237
+ ROOT under a random name. Both are write-then-rename scratch, both are
238
+ left behind by the failure this project's own tuition calls ordinary
239
+ rather than exceptional (a process killed mid-write), and once either is
240
+ committed it is tracked forever by ordinary git semantics.
241
+
242
+ A REFUSAL AND NOT A SILENT EXCLUSION, for the reason criterion 4 already
243
+ gives about a staged ephemeral path: this command must not quietly drop
244
+ a path an operator can see in `git status`. The cost is stated rather
245
+ than discovered: a `tiphys sync` that races a live `tiphys status emit`
246
+ now fails with a named path instead of committing a temp file, and the
247
+ operator re-runs.
248
+
249
+ THE FLEET IGNORE SET IS NOT WIDENED TO DO THIS, and this module still
250
+ holds no copy of it. That constant also drives `EPHEMERAL_DIRS`,
251
+ `DURABLE_DIRS` and the `.gitignore` `tiphys init` writes, so a glob
252
+ added there would become a directory name `tiphys resume` tried to
253
+ rebuild. Nothing stops being synced; this is a second, separately named
254
+ rule about SUFFIXES, declared once in src/fleet.ts and read from there,
255
+ exactly as the ignore question is asked of git rather than answered
256
+ here. */
257
+ const scratch = [...new Set(changed.map((entry) => entry.path))]
258
+ .filter((path) => !ephemeral.has(path))
259
+ .map((path) => ({ path, suffix: isFleetScratchPath(path) }))
260
+ .filter((candidate) => candidate.suffix !== undefined)
261
+ .sort((a, b) => a.path.localeCompare(b.path));
262
+ if (scratch.length > 0) {
263
+ for (const candidate of scratch) {
264
+ process.stderr.write(`tiphys sync: ${candidate.path} ends in ${candidate.suffix}, which is a ` +
265
+ `write-then-rename scratch suffix this kernel uses (${FLEET_SCRATCH_SUFFIXES.join(", ")}), ` +
266
+ `and no fleet .gitignore rule covers it, so committing it would track it forever; ` +
267
+ `remove it and re-run, nothing was committed\n`);
268
+ }
269
+ return 1;
270
+ }
271
+ /* CRITERION 4, and the order matters: every staged ephemeral path is
272
+ reported before anything is staged or committed, so the refusal is a
273
+ statement about the tree as the operator left it. */
274
+ const stagedEphemeral = changed.filter((entry) => ephemeral.has(entry.path) && entry.index !== " " && entry.index !== "?");
275
+ if (stagedEphemeral.length > 0) {
276
+ for (const entry of stagedEphemeral) {
277
+ const rule = ephemeral.get(entry.path);
278
+ process.stderr.write(`tiphys sync: ${entry.path} is staged and is ephemeral by ${renderRule(rule)}; ` +
279
+ `unstage it with git restore --staged -- ${entry.path} and re-run, nothing was committed\n`);
280
+ }
281
+ return 1;
282
+ }
283
+ const durable = [
284
+ ...new Set(changed
285
+ .map((entry) => entry.path)
286
+ .filter((path) => !ephemeral.has(path))),
287
+ ].sort();
288
+ const excluded = [
289
+ ...new Set(changed.map((entry) => entry.path).filter((path) => ephemeral.has(path))),
290
+ ].sort();
291
+ /* The excluded paths are PRINTED WITH THE RULE THAT EXCLUDED THEM. A
292
+ command that silently drops paths is indistinguishable from one that
293
+ never saw them, and the rule reference is what makes the derivation
294
+ observable rather than asserted. */
295
+ for (const path of excluded) {
296
+ process.stdout.write(`EXCLUDED ${path} ${renderRule(ephemeral.get(path))}\n`);
297
+ }
298
+ if (durable.length === 0) {
299
+ process.stdout.write("NOTHING TO COMMIT\n");
300
+ }
301
+ else {
302
+ const added = runGit(fleet.root, ["add", "--", ...durable]);
303
+ if (added.status !== 0) {
304
+ process.stderr.write(`tiphys sync: git add failed in ${fleet.root}: ${added.stderr.split("\n")[0] ?? ""}\n`);
305
+ return 1;
306
+ }
307
+ const committed = runGit(fleet.root, ["commit", "-m", syncCommitMessage(durable.length)], {
308
+ /* The documented deterministic machine identity, command-scoped,
309
+ exactly as the bootstrap commit does it: CI runners have no git
310
+ identity and this must never touch user or global config
311
+ (CLAUDE.md standing warning 5). */
312
+ extraEnv: {
313
+ GIT_AUTHOR_NAME: MACHINE_IDENTITY_NAME,
314
+ GIT_AUTHOR_EMAIL: MACHINE_IDENTITY_EMAIL,
315
+ GIT_COMMITTER_NAME: MACHINE_IDENTITY_NAME,
316
+ GIT_COMMITTER_EMAIL: MACHINE_IDENTITY_EMAIL,
317
+ },
318
+ });
319
+ if (committed.status !== 0) {
320
+ process.stderr.write(`tiphys sync: git commit failed in ${fleet.root}: ${committed.stderr.split("\n")[0] ?? ""}\n`);
321
+ return 1;
322
+ }
323
+ for (const path of durable) {
324
+ process.stdout.write(`COMMITTED ${path}\n`);
325
+ }
326
+ }
327
+ /* THE PUSH ARM IS NOT OPTIONAL AND ITS FAILURE IS NOT A WARNING. The
328
+ failure carries GIT'S OWN first stderr line rather than a message
329
+ composed here: a retry signature or a diagnosis derived from a
330
+ hand-written example is the failure T-003 and CLAUDE.md standing warning
331
+ 10 both record. */
332
+ const pushed = runGit(fleet.root, ["push", remote, "HEAD"]);
333
+ if (pushed.status !== 0) {
334
+ const first = pushed.stderr.split("\n")[0] ?? "";
335
+ process.stderr.write(`tiphys sync: git push to ${remote} exited ${String(pushed.status)}, ` +
336
+ `so the durable state is committed locally and NOT pushed: ${first}\n`);
337
+ return 1;
338
+ }
339
+ process.stdout.write(`PUSHED ${remote}\n`);
340
+ return 0;
341
+ }
@@ -14,7 +14,7 @@ import { teardownTask } from "../teardown.js";
14
14
  * survivors are enumerated); this command adds no framing of its own to
15
15
  * either, because the two must never be described alike.
16
16
  */
17
- const USAGE = "usage: tiphys teardown --task <id> [--salvage]";
17
+ const USAGE = "usage: tiphys teardown --task <id> [--salvage] [--from-reconstructed]";
18
18
  function usageError(message) {
19
19
  if (message !== undefined) {
20
20
  process.stderr.write(`tiphys teardown: ${message}\n`);
@@ -23,7 +23,11 @@ function usageError(message) {
23
23
  return EX_USAGE;
24
24
  }
25
25
  function parseFlags(args) {
26
- const parsed = { task: undefined, salvage: false };
26
+ const parsed = {
27
+ task: undefined,
28
+ salvage: false,
29
+ fromReconstructed: false,
30
+ };
27
31
  for (let i = 0; i < args.length; i += 1) {
28
32
  const flag = args[i];
29
33
  const value = args[i + 1];
@@ -34,6 +38,9 @@ function parseFlags(args) {
34
38
  else if (flag === "--salvage") {
35
39
  parsed.salvage = true;
36
40
  }
41
+ else if (flag === "--from-reconstructed") {
42
+ parsed.fromReconstructed = true;
43
+ }
37
44
  else {
38
45
  return undefined;
39
46
  }
@@ -64,6 +71,7 @@ export async function cmdTeardown(args) {
64
71
  const result = await teardownTask(fleet, {
65
72
  taskId: flags.task,
66
73
  salvage: flags.salvage,
74
+ fromReconstructed: flags.fromReconstructed,
67
75
  });
68
76
  if (!result.ok) {
69
77
  // Plan step 5: "every refusal is exit nonzero plus a single reason