triad-plus 1.10.0 → 1.12.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.
@@ -31,6 +31,37 @@ adapter writes those into host-native profiles only where the selected host
31
31
  supports that facility. A blank model means the host default. Never put tokens,
32
32
  API keys, or private deployment data in this file.
33
33
 
34
+ ## Installation ownership and version
35
+
36
+ The package version and the installed workspace version are separate facts:
37
+
38
+ ```bash
39
+ npx triad-plus --version
40
+ npx triad-plus version --control /path/to/project-control
41
+ ```
42
+
43
+ After successful materialization, `.triad-plus/installation.json` records the
44
+ adapter, project/global scopes, exact managed files, SHA-256 hashes, timestamps,
45
+ and a deterministic fingerprint. Project paths are control-workspace-relative;
46
+ global paths identify the user-level managed asset. The manifest is generated
47
+ from the same adapter registry and install plan used by `init` and `upgrade`.
48
+
49
+ The manifest deliberately does not own `.triad-plus/team.json`, `.loop/`,
50
+ `project.yaml`, feature cards, artifacts, evidence, or product source. A failed
51
+ install never writes a success manifest. Legacy workspaces are migrated by
52
+ `upgrade --apply` using the currently executing package version; the previous
53
+ version is not guessed.
54
+
55
+ When a team configuration is materialized, the managed role-run block in
56
+ `AGENTS.md` is recorded as a bounded block asset. Uninstall removes that block
57
+ only when its markers and hash still match, never the surrounding user file.
58
+ Global assets are shared-capable and therefore preserved by default by
59
+ `uninstall --global`.
60
+
61
+ `doctor` reports CLI version, installed version, manifest state, adapter, and
62
+ project/global scope. It reports version skew explicitly and never queries npm
63
+ for `latest`.
64
+
34
65
  ## Configure models through the agent
35
66
 
36
67
  The installed `triad-model-configuration` skill lets an owner ask the selected
@@ -0,0 +1,52 @@
1
+ # Human-readable reports
2
+
3
+ Triad+ keeps JSON/YAML control records, verification evidence, review reports,
4
+ candidate fingerprints, and delivery records as the source of truth. From those
5
+ records the Orchestrator can materialize two portable Markdown views:
6
+
7
+ - one `card-reports/<card-id>.md` for every terminal Card;
8
+ - one final delivery handoff with a human-first summary and links to the Card
9
+ reports.
10
+
11
+ The report renderer is deterministic and does not make an additional LLM call.
12
+ It attributes changed paths to the complete Card baseline, so a rework attempt
13
+ does not hide files changed by an earlier attempt. Reports contain relative
14
+ evidence references and redact unrelated absolute paths.
15
+
16
+ ## Materialize a Card report
17
+
18
+ The Orchestrator first writes a small derived JSON context from the canonical
19
+ Card, attempt, verifier, Reviewer, and delivery records. The source Card and
20
+ those records remain read-only. Then run:
21
+
22
+ ```bash
23
+ node .triad-runtime/triad-human-report.mjs \
24
+ --mode card \
25
+ --project /absolute/path/to/control-workspace \
26
+ --input .loop/runtime/report-context/CARD-001.json \
27
+ --output card-reports/CARD-001.md \
28
+ --worktree /absolute/path/to/product-worktree \
29
+ --base-commit <card-baseline-commit>
30
+ ```
31
+
32
+ For an approved Card the renderer requires a final commit, candidate
33
+ fingerprint, passing verifier evidence, independent Reviewer approval, and the
34
+ complete changed-path manifest. A blocked or not-delivered terminal Card must
35
+ include a truthful reason and never claims delivery. Writes are atomic and
36
+ re-running the command with the same context is idempotent.
37
+
38
+ ## Materialize the final handoff
39
+
40
+ ```bash
41
+ node .triad-runtime/triad-human-report.mjs \
42
+ --mode handoff \
43
+ --project /absolute/path/to/control-workspace \
44
+ --input .loop/runtime/report-context/delivery.json \
45
+ --output handoff.md
46
+ ```
47
+
48
+ The handoff opens with the executive summary and Card results, then preserves
49
+ links to the technical evidence, branch/commit map, quality-contract closure,
50
+ Evaluator+ result, delivery gates, demo details, risks, and practical test.
51
+ It is a view for people, not an alternative delivery state machine.
52
+
@@ -22,11 +22,68 @@ npx triad-plus init --host codex --control /path/to/project-control --global
22
22
  npx triad-plus doctor --host codex --control /path/to/project-control
23
23
  ```
24
24
 
25
- ## Upgrade an existing control workspace
25
+ ## Version visibility
26
26
 
27
- `upgrade` refreshes only Triad-managed runtime, skill, adapter, and optional
28
- host-entry assets. It never changes `team.json`, `.loop/`, PRD files, evidence,
29
- or product repositories. The default is a dry run:
27
+ `--version` reports the package that is actually executing:
28
+
29
+ ```bash
30
+ npx triad-plus --version
31
+ ```
32
+
33
+ The workspace command reports the materialized installation recorded by the
34
+ control workspace manifest:
35
+
36
+ ```bash
37
+ npx triad-plus version --control /path/to/project-control
38
+ ```
39
+
40
+ An older workspace without `.triad-plus/installation.json` is reported as a
41
+ legacy installation. Triad+ does not infer a historical version from scattered
42
+ agent files.
43
+
44
+ ## Installation manifest and safe uninstall
45
+
46
+ After a successful `init`, Triad+ writes
47
+ `.triad-plus/installation.json`. It records the selected adapter, CLI version,
48
+ project/global scopes, normalized managed-file paths, SHA-256 hashes, and a
49
+ manifest fingerprint. `upgrade --apply` refreshes this record after managed
50
+ assets are materialized; a legacy workspace receives a new manifest without
51
+ inventing its previous version.
52
+
53
+ Uninstall is dry-run by default:
54
+
55
+ ```bash
56
+ npx triad-plus uninstall --host opencode --control /path/to/project-control
57
+ npx triad-plus uninstall --host opencode --control /path/to/project-control --apply
58
+ npx triad-plus uninstall --host opencode --control /path/to/project-control --global --apply
59
+ ```
60
+
61
+ Only files listed in the manifest, still unchanged from their recorded hash,
62
+ are removed. Missing files are reported as `ABSENT`; modified assets are
63
+ preserved. Host directories are never pruned because directory ownership is
64
+ not claimed. The team configuration, `.loop/`, project manifest, feature
65
+ cards, artifacts, evidence, and other user state are preserved. A managed
66
+ `AGENTS.md` role-run block is tracked separately and removed only when its
67
+ markers and hash are exact; surrounding user content remains. A manifest
68
+ remains as an `uninstalled` or `partial` tombstone so a second uninstall is
69
+ idempotent and the ownership history is auditable.
70
+
71
+ `--global --apply` is deliberately preserve-by-default: global assets may be
72
+ shared by several control workspaces, so the command reports them as shared
73
+ and does not delete them without a cross-workspace ownership model.
74
+
75
+ Read-only commands require an existing control workspace; a typo path is
76
+ never created by `version` or `uninstall`.
77
+
78
+ ## Upgrade, repair, or restore an existing control workspace
79
+
80
+ `init` is the first-install command and refuses to overwrite an existing
81
+ workspace. `upgrade --apply` is the managed update, repair, and restore path
82
+ for a workspace that is already registered by an installation manifest,
83
+ including a workspace whose project scope is `uninstalled` or `partial` after a
84
+ safe uninstall. It re-materializes project assets, reuses the preserved
85
+ `team.json`, and refreshes the manifest without changing user state, `.loop/`,
86
+ PRD files, evidence, or product repositories. The default is a dry run:
30
87
 
31
88
  ```bash
32
89
  npx triad-plus upgrade --host codex --control /path/to/project-control --global
@@ -96,6 +96,15 @@ inserire token o segreti. Gli hook sono un’ottimizzazione, non autorità: il
96
96
  dispatch esplicito della verification resta sempre disponibile. Vedi la
97
97
  [matrice di compatibilità](compatibility.md).
98
98
 
99
+ Per ogni Card terminale l’Orchestrator può generare una vista deterministica
100
+ `card-reports/<card-id>.md` dai record canonici di Card, attempt, verifier e
101
+ Reviewer. Il renderer attribuisce al baseline completo della Card il delta dei
102
+ path modificati, inclusi rework, rinomina e cancellazione. L’handoff finale si
103
+ apre con un riepilogo leggibile e collega questi report, mantenendo anche le
104
+ evidence tecniche di delivery. I report sono scritti atomicamente, sono
105
+ ripetibili e usano riferimenti relativi: non sono una nuova source of truth né
106
+ una chiamata aggiuntiva a un agente. Vedi la [guida ai report leggibili](human-readable-reports.md).
107
+
99
108
  Per ogni demo configurata, registra nel progetto e nell’handoff comando, URL
100
109
  locale, modalità di accesso remoto e URL remoto. `localhost` è solo locale: non
101
110
  va indicato a chi prova da remoto. Avvia il servizio soltanto su richiesta del
@@ -95,6 +95,15 @@ reports, and handoff in a separate project-control workspace. Never place tokens
95
95
  or secrets there. Hooks are an optimization, not authority; explicit verification
96
96
  is always the fallback. See the [compatibility matrix](compatibility.md).
97
97
 
98
+ For every terminal Card, the Orchestrator may render a deterministic
99
+ `card-reports/<card-id>.md` view from the canonical Card, attempt, verifier, and
100
+ Reviewer records. The renderer attributes the complete changed-path delta to the
101
+ Card baseline, including rework, renames, and deletions. The final handoff opens
102
+ with a human-readable summary and links to those reports while retaining the
103
+ technical delivery evidence. Reports are atomic, repeatable, relative-path
104
+ views; they are not a new source of truth or an additional agent call. See the
105
+ [human-readable reports guide](human-readable-reports.md).
106
+
98
107
  For a configured demo service, record its command, local URL, remote-access mode,
99
108
  and remote URL in the project and handoff. `localhost` is local-only; do not give
100
109
  it to a remote tester as a reachable endpoint. Start the service only on the
@@ -6,6 +6,26 @@ Run doctor first:
6
6
  npx triad-plus doctor --host <runtime> --control /path/to/triad-control
7
7
  ```
8
8
 
9
+ Compare the executing CLI with the materialized workspace installation:
10
+
11
+ ```bash
12
+ npx triad-plus --version
13
+ npx triad-plus version --control /path/to/triad-control
14
+ ```
15
+
16
+ `legacy / manifest missing` means the workspace predates the installation
17
+ manifest. Run `upgrade --apply` to materialize a current manifest; Triad+ does
18
+ not infer the old version. The same `upgrade --apply` command is the managed
19
+ restore path after a safe uninstall leaves an `uninstalled` or `partial`
20
+ manifest: it re-materializes managed assets and reuses the preserved team
21
+ configuration and user state. `init` is reserved for first installation and
22
+ refuses existing paths. `manifest invalid` means the ownership record or its
23
+ fingerprint is malformed and should be reviewed before any uninstall.
24
+
25
+ Doctor also reports `CLI newer / upgrade available` and `CLI older than
26
+ installed version` instead of silently claiming compatibility. It never checks
27
+ the npm `latest` tag.
28
+
9
29
  `not installed` means the selected adapter assets are absent from that control
10
30
  workspace. `not installed or version unavailable` for a host means its binary is
11
31
  not on PATH or otherwise cannot answer `--version`. Re-run the appropriate host
@@ -20,3 +40,24 @@ baselines, worktree, branch, and candidate. Do not treat it as a passing test.
20
40
 
21
41
  If Evaluator+ is unavailable, check `roles.evaluator.enabled` in the team file.
22
42
  An Evaluator+ failure is post-run information, not an automatic repair request.
43
+
44
+ ## Safe uninstall
45
+
46
+ Uninstall is a dry run unless `--apply` is supplied:
47
+
48
+ ```bash
49
+ npx triad-plus uninstall --host <runtime> --control /path/to/triad-control
50
+ npx triad-plus uninstall --host <runtime> --control /path/to/triad-control --apply
51
+ ```
52
+
53
+ Only unchanged files listed in `.triad-plus/installation.json` are removed.
54
+ `ABSENT` files are harmless. A `PRESERVE` line means the file was modified, no
55
+ longer matches the trusted managed plan, or is not a regular file; no force
56
+ option exists for this operation. Add `--global` only when user-level Triad
57
+ assets should also be considered. Global assets are nevertheless preserved by
58
+ default because another workspace may share them. Team config, loop state,
59
+ cards, artifacts, evidence, generic host directories, and other user files are
60
+ preserved. A managed `AGENTS.md` block is removed only when its exact markers
61
+ and hash still match; a modified or ambiguous block leaves the uninstall
62
+ partial. `version` and `uninstall` reject nonexistent control paths without
63
+ creating them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triad-plus",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "A lightweight, evidence-backed engineering loop for coding agents.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "node": ">=20"
29
29
  },
30
30
  "scripts": {
31
- "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs",
31
+ "test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/installation-manifest-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs && node tests/human-reports-test.mjs",
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "repository": {
@@ -29,3 +29,14 @@ export async function writeAtomicJson(targetPath, value) {
29
29
  await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
30
30
  await rename(temporaryPath, targetPath);
31
31
  }
32
+
33
+ /**
34
+ * Persist a derived human-readable artifact without exposing a partially
35
+ * written report to the owner or to a resumed Orchestrator.
36
+ */
37
+ export async function writeAtomicText(targetPath, value) {
38
+ await mkdir(path.dirname(targetPath), { recursive: true });
39
+ const temporaryPath = `${targetPath}.tmp`;
40
+ await writeFile(temporaryPath, String(value), "utf8");
41
+ await rename(temporaryPath, targetPath);
42
+ }
@@ -26,6 +26,12 @@ async function gitOutput(worktree, args) {
26
26
  return result.stdout;
27
27
  }
28
28
 
29
+ async function verifiedCommit(root, commit, label) {
30
+ const resolved = (await gitLines(root, ["rev-parse", "--verify", `${commit}^{commit}`]))[0];
31
+ if (!resolved) throw new Error(`${label} does not resolve to a commit`);
32
+ return resolved;
33
+ }
34
+
29
35
  function ignored(relativePath) {
30
36
  return SENSITIVE_PATH.test(relativePath) || IGNORED_PATH.test(relativePath);
31
37
  }
@@ -84,6 +90,63 @@ export async function collectCandidateChanges(worktree, { baseCommit = null } =
84
90
  return { git_head: head, base_commit: base, changes, ignored_paths: [...new Set(ignored_paths)].sort() };
85
91
  }
86
92
 
93
+ /**
94
+ * Collect a complete candidate delta from an immutable Git commit rather than
95
+ * from whatever happens to be in the current worktree. This is used by derived
96
+ * reports so a later Card on the same branch cannot contaminate an earlier one.
97
+ */
98
+ export async function collectCandidateChangesAtCommit(worktree, { baseCommit, commit }) {
99
+ const root = await realpath(worktree);
100
+ const base = await verifiedCommit(root, baseCommit, "base commit");
101
+ const head = await verifiedCommit(root, commit, "final commit");
102
+ const ancestry = await runProcess("git", ["merge-base", "--is-ancestor", base, head], { cwd: root, timeoutMs: 15_000 });
103
+ if (ancestry.exitCode !== 0) throw new Error("card baseline is not an ancestor of the final commit");
104
+ const tracked = parseNameStatus(await gitOutput(root, ["diff", "--name-status", "--find-renames", base, head]));
105
+ const changes = [];
106
+ const ignored_paths = [];
107
+ for (const entry of tracked) {
108
+ const entryPaths = pathsFor(entry);
109
+ if (entryPaths.some(ignored)) {
110
+ ignored_paths.push(...entryPaths.filter(ignored));
111
+ continue;
112
+ }
113
+ for (const relativePath of entryPaths) {
114
+ const absolutePath = path.resolve(root, relativePath);
115
+ if (!absolutePath.startsWith(`${root}${path.sep}`)) throw new Error(`unsafe changed path: ${relativePath}`);
116
+ }
117
+ changes.push(entry);
118
+ }
119
+ return { git_head: head, base_commit: base, changes, ignored_paths: [...new Set(ignored_paths)].sort() };
120
+ }
121
+
122
+ async function commitFileHash(root, commit, relativePath) {
123
+ const result = await runProcess("git", ["show", `${commit}:${relativePath}`], { cwd: root, timeoutMs: 15_000 });
124
+ if (result.exitCode !== 0) throw new Error(`git show failed for ${commit}:${relativePath}`);
125
+ return digest(result.stdout);
126
+ }
127
+
128
+ /** Calculate the same candidate fingerprint algorithm at a specific commit. */
129
+ export async function calculateCandidateFingerprintAtCommit(worktree, { baseCommit, commit }) {
130
+ const root = await realpath(worktree);
131
+ const candidate = await collectCandidateChangesAtCommit(root, { baseCommit, commit });
132
+ const files = [];
133
+ const changed = new Map();
134
+ for (const entry of candidate.changes) {
135
+ if (entry.status === "renamed") {
136
+ changed.set(entry.source, "DELETED");
137
+ changed.set(entry.destination, null);
138
+ } else {
139
+ changed.set(entry.path, entry.status === "deleted" ? "DELETED" : null);
140
+ }
141
+ }
142
+ for (const [relativePath, knownHash] of [...changed.entries()].sort(([left], [right]) => left.localeCompare(right))) {
143
+ const contentHash = knownHash === null ? await commitFileHash(root, candidate.git_head, relativePath) : knownHash;
144
+ files.push({ path: relativePath, sha256: contentHash });
145
+ }
146
+ const canonical = JSON.stringify({ git_head: candidate.git_head, files });
147
+ return { algorithm: "sha256", value: digest(canonical), git_head: candidate.git_head, files };
148
+ }
149
+
87
150
  export async function worktreeBranch(worktree) {
88
151
  const root = await realpath(worktree);
89
152
  return (await gitLines(root, ["branch", "--show-current"]))[0] ?? "DETACHED";