@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.
- package/AGENTS.md +56 -4
- package/assurance-modes.yaml +23 -2
- package/dist/bin/tiphys.js +86 -8
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.js +27 -20
- package/dist/src/checks.d.ts +720 -9
- package/dist/src/checks.js +1874 -163
- package/dist/src/cli.js +11 -0
- package/dist/src/commands/brief.js +27 -4
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +229 -0
- package/dist/src/commands/doctor.js +968 -27
- package/dist/src/commands/init.d.ts +3 -3
- package/dist/src/commands/init.js +57 -8
- package/dist/src/commands/lock.d.ts +33 -0
- package/dist/src/commands/lock.js +117 -6
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/pool.js +12 -1
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.js +51 -2
- package/dist/src/commands/status.d.ts +6 -4
- package/dist/src/commands/status.js +6 -4
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.js +10 -2
- package/dist/src/commands/validate.js +70 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +152 -2
- package/dist/src/exec/env.js +146 -2
- package/dist/src/fleet.d.ts +172 -0
- package/dist/src/fleet.js +219 -1
- package/dist/src/gates/citations.js +7 -1
- package/dist/src/gates/coverage.d.ts +113 -22
- package/dist/src/gates/coverage.js +166 -31
- package/dist/src/gates/credentials.d.ts +159 -0
- package/dist/src/gates/credentials.js +221 -2
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/red-witness.js +105 -13
- package/dist/src/gates/run.d.ts +49 -1
- package/dist/src/gates/run.js +83 -5
- package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
- package/dist/src/gates/suite.js +48 -7
- package/dist/src/hooks.d.ts +55 -3
- package/dist/src/hooks.js +69 -6
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/lock.d.ts +82 -4
- package/dist/src/lock.js +314 -22
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/path-identity.d.ts +32 -0
- package/dist/src/path-identity.js +38 -0
- package/dist/src/pool.d.ts +197 -1
- package/dist/src/pool.js +289 -22
- package/dist/src/roles.d.ts +31 -0
- package/dist/src/roles.js +42 -0
- package/dist/src/spawn.d.ts +307 -2
- package/dist/src/spawn.js +690 -19
- package/dist/src/status.d.ts +27 -2
- package/dist/src/status.js +34 -5
- package/dist/src/task.d.ts +295 -55
- package/dist/src/task.js +125 -123
- package/dist/src/teardown.d.ts +7 -0
- package/dist/src/teardown.js +120 -12
- package/dist/src/validate.d.ts +44 -11
- package/dist/src/validate.js +44 -34
- package/dist/src/watcher.js +1 -11
- package/dist/src/witness/run.d.ts +32 -7
- package/dist/src/witness/run.js +76 -30
- package/dist/src/witness/spec.d.ts +168 -0
- package/dist/src/witness/spec.js +240 -18
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/gate-registry.yaml +136 -0
- package/gates.manifest.json +63 -1
- package/package.json +18 -3
- package/roles/implementer.md +3 -0
- package/schemas/README.md +1 -0
- package/schemas/assurance-modes.schema.json +1 -1
- package/schemas/charter.schema.json +19 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/verdict.schema.json +9 -3
- 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
|
-
*
|
|
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
|
|
11
|
-
* atomically. `show` reads `current.json` ONLY and
|
|
12
|
-
* (constraint C-1: never read current state from the
|
|
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
|
|
11
|
-
* atomically. `show` reads `current.json` ONLY and
|
|
12
|
-
* (constraint C-1: never read current state from the
|
|
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 = {
|
|
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
|