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.
@@ -0,0 +1,269 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { access, lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const INSTALLATION_MANIFEST_SCHEMA_VERSION = 1;
6
+ export const INSTALLATION_MANIFEST_PATH = '.triad-plus/installation.json';
7
+
8
+ const SHA256 = /^[a-f0-9]{64}$/i;
9
+ const SCOPES = new Set(['project', 'global']);
10
+ const SCOPE_STATES = new Set(['installed', 'partial', 'uninstalled', 'not_configured']);
11
+ const TOP_LEVEL_KEYS = new Set([
12
+ 'schema_version',
13
+ 'triad_version',
14
+ 'adapter',
15
+ 'installed_at',
16
+ 'updated_at',
17
+ 'uninstalled_at',
18
+ 'scopes',
19
+ 'scope_status',
20
+ 'managed_assets',
21
+ 'status',
22
+ 'fingerprint'
23
+ ]);
24
+ const ASSET_KEYS = new Set(['scope', 'path', 'kind', 'sha256', 'start_marker', 'end_marker']);
25
+
26
+ function invalid(message) {
27
+ const error = new Error(message);
28
+ error.code = 'installation_manifest_invalid';
29
+ return error;
30
+ }
31
+
32
+ function objectLike(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ function digest(value) {
37
+ return createHash('sha256').update(value).digest('hex');
38
+ }
39
+
40
+ /**
41
+ * Return a stable JSON-compatible value with object keys ordered recursively.
42
+ * Array order is intentionally preserved because the manifest is an ordered
43
+ * record of the managed install plan.
44
+ */
45
+ export function canonicalize(value) {
46
+ if (Array.isArray(value)) return value.map((item) => canonicalize(item));
47
+ if (!objectLike(value)) return value;
48
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
49
+ }
50
+
51
+ export function canonicalJson(value) {
52
+ return JSON.stringify(canonicalize(value));
53
+ }
54
+
55
+ export function installationManifestPayload(manifest) {
56
+ const { fingerprint: _fingerprint, ...payload } = manifest;
57
+ return payload;
58
+ }
59
+
60
+ export function installationManifestFingerprint(manifest) {
61
+ return digest(canonicalJson(installationManifestPayload(manifest)));
62
+ }
63
+
64
+ function normalizeRelative(value) {
65
+ if (typeof value !== 'string' || !value.trim()) throw invalid('installation manifest asset path must be non-empty');
66
+ if (value.includes('\0') || path.isAbsolute(value)) throw invalid(`installation manifest project path must be relative: ${value}`);
67
+ const normalized = value.split(path.sep).join('/');
68
+ const parts = normalized.split('/');
69
+ if (parts.some((part) => !part || part === '.' || part === '..')) {
70
+ throw invalid(`installation manifest path contains traversal or empty segments: ${value}`);
71
+ }
72
+ if (path.posix.normalize(normalized) !== normalized) throw invalid(`installation manifest path is not normalized: ${value}`);
73
+ return normalized;
74
+ }
75
+
76
+ function normalizeAbsolute(value) {
77
+ if (typeof value !== 'string' || !value.trim() || value.includes('\0') || !path.isAbsolute(value)) {
78
+ throw invalid(`installation manifest global path must be absolute: ${value}`);
79
+ }
80
+ const normalized = path.resolve(value);
81
+ if (normalized !== value) throw invalid(`installation manifest global path is not normalized: ${value}`);
82
+ return normalized;
83
+ }
84
+
85
+ function validateTimestamp(value, label) {
86
+ if (typeof value !== 'string' || !value.trim() || Number.isNaN(Date.parse(value))) {
87
+ throw invalid(`installation manifest ${label} must be an ISO timestamp`);
88
+ }
89
+ }
90
+
91
+ function rejectUnknown(value, allowed, label) {
92
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw invalid(`${label} has unknown property: ${key}`);
93
+ }
94
+
95
+ /** Validate a manifest, including its self-declared fingerprint. */
96
+ export function validateInstallationManifest(manifest) {
97
+ if (!objectLike(manifest)) throw invalid('installation manifest must be a JSON object');
98
+ rejectUnknown(manifest, TOP_LEVEL_KEYS, 'installation manifest');
99
+ if (manifest.schema_version !== INSTALLATION_MANIFEST_SCHEMA_VERSION) {
100
+ throw invalid(`installation manifest schema_version must be ${INSTALLATION_MANIFEST_SCHEMA_VERSION}`);
101
+ }
102
+ if (typeof manifest.triad_version !== 'string' || !manifest.triad_version.trim()) throw invalid('installation manifest triad_version must be non-empty');
103
+ if (typeof manifest.adapter !== 'string' || !manifest.adapter.trim()) throw invalid('installation manifest adapter must be non-empty');
104
+ validateTimestamp(manifest.installed_at, 'installed_at');
105
+ validateTimestamp(manifest.updated_at, 'updated_at');
106
+ if (manifest.uninstalled_at !== undefined) validateTimestamp(manifest.uninstalled_at, 'uninstalled_at');
107
+
108
+ if (!objectLike(manifest.scopes)) throw invalid('installation manifest scopes must be an object');
109
+ rejectUnknown(manifest.scopes, SCOPES, 'installation manifest scopes');
110
+ if (typeof manifest.scopes.project !== 'boolean' || typeof manifest.scopes.global !== 'boolean') {
111
+ throw invalid('installation manifest scopes.project and scopes.global must be booleans');
112
+ }
113
+
114
+ if (manifest.scope_status !== undefined) {
115
+ if (!objectLike(manifest.scope_status)) throw invalid('installation manifest scope_status must be an object');
116
+ rejectUnknown(manifest.scope_status, SCOPES, 'installation manifest scope_status');
117
+ for (const scope of SCOPES) {
118
+ if (!SCOPE_STATES.has(manifest.scope_status[scope])) throw invalid(`installation manifest scope_status.${scope} is invalid`);
119
+ }
120
+ }
121
+
122
+ if (!Array.isArray(manifest.managed_assets)) throw invalid('installation manifest managed_assets must be an array');
123
+ const seen = new Set();
124
+ for (const asset of manifest.managed_assets) {
125
+ if (!objectLike(asset)) throw invalid('installation manifest managed asset must be an object');
126
+ rejectUnknown(asset, ASSET_KEYS, 'installation manifest managed asset');
127
+ if (!SCOPES.has(asset.scope)) throw invalid(`installation manifest managed asset scope is invalid: ${asset.scope}`);
128
+ const normalized = asset.scope === 'project' ? normalizeRelative(asset.path) : normalizeAbsolute(asset.path);
129
+ if (normalized !== asset.path) throw invalid(`installation manifest managed asset path is not normalized: ${asset.path}`);
130
+ if (asset.kind === 'file') {
131
+ if (asset.start_marker !== undefined || asset.end_marker !== undefined) {
132
+ throw invalid(`installation manifest file asset cannot contain managed block markers: ${asset.path}`);
133
+ }
134
+ } else if (asset.kind === 'managed_block') {
135
+ if (asset.scope !== 'project') throw invalid(`installation manifest managed block must be project-scoped: ${asset.path}`);
136
+ if (typeof asset.start_marker !== 'string' || !asset.start_marker || typeof asset.end_marker !== 'string' || !asset.end_marker || asset.start_marker === asset.end_marker) {
137
+ throw invalid(`installation manifest managed block markers are invalid: ${asset.path}`);
138
+ }
139
+ } else {
140
+ throw invalid(`installation manifest managed asset kind is unsupported: ${asset.kind}`);
141
+ }
142
+ if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) throw invalid(`installation manifest managed asset sha256 is invalid: ${asset.path}`);
143
+ const key = `${asset.scope}:${asset.path}`;
144
+ if (seen.has(key)) throw invalid(`installation manifest managed asset is duplicated: ${key}`);
145
+ seen.add(key);
146
+ }
147
+
148
+ if (manifest.status !== undefined && !SCOPE_STATES.has(manifest.status)) throw invalid(`installation manifest status is invalid: ${manifest.status}`);
149
+ if (typeof manifest.fingerprint !== 'string' || !SHA256.test(manifest.fingerprint)) throw invalid('installation manifest fingerprint is invalid');
150
+ const calculated = installationManifestFingerprint(manifest);
151
+ if (manifest.fingerprint.toLowerCase() !== calculated) throw invalid('installation manifest fingerprint does not match its contents');
152
+ return manifest;
153
+ }
154
+
155
+ export function installationManifestPath(controlRoot) {
156
+ return path.join(controlRoot, INSTALLATION_MANIFEST_PATH);
157
+ }
158
+
159
+ export async function loadInstallationManifest(controlRoot) {
160
+ const manifestPath = installationManifestPath(controlRoot);
161
+ try {
162
+ await access(manifestPath);
163
+ } catch (error) {
164
+ if (error.code === 'ENOENT') return null;
165
+ throw error;
166
+ }
167
+ let manifest;
168
+ try {
169
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
170
+ } catch (error) {
171
+ throw invalid(`installation manifest is not valid JSON: ${error.message}`);
172
+ }
173
+ validateInstallationManifest(manifest);
174
+ return { path: manifestPath, manifest };
175
+ }
176
+
177
+ export async function writeInstallationManifest(controlRoot, manifest) {
178
+ validateInstallationManifest(manifest);
179
+ const target = installationManifestPath(controlRoot);
180
+ await mkdir(path.dirname(target), { recursive: true });
181
+ const temporary = `${target}.tmp`;
182
+ await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
183
+ await rename(temporary, target);
184
+ return target;
185
+ }
186
+
187
+ function within(root, target) {
188
+ const base = path.resolve(root);
189
+ const resolved = path.resolve(target);
190
+ return resolved === base || resolved.startsWith(`${base}${path.sep}`);
191
+ }
192
+
193
+ async function walkFiles(target, files) {
194
+ let info;
195
+ try { info = await lstat(target); } catch (error) {
196
+ if (error.code === 'ENOENT') return;
197
+ throw error;
198
+ }
199
+ if (info.isDirectory()) {
200
+ for (const name of (await readdir(target)).sort()) await walkFiles(path.join(target, name), files);
201
+ return;
202
+ }
203
+ if (!info.isFile()) throw invalid(`managed installation asset is not a regular file: ${target}`);
204
+ files.push(target);
205
+ }
206
+
207
+ export async function sha256File(filePath) {
208
+ return digest(await readFile(filePath));
209
+ }
210
+
211
+ export function sha256Text(value) {
212
+ return digest(value);
213
+ }
214
+
215
+ /** Collect the exact regular files materialized below one or more asset roots. */
216
+ export async function collectManagedAssetRecords(roots, { scope, baseRoot = null } = {}) {
217
+ if (!SCOPES.has(scope)) throw new Error(`unknown installation asset scope: ${scope}`);
218
+ const files = [];
219
+ for (const root of [...new Set(roots.map((item) => path.resolve(item)))]) await walkFiles(root, files);
220
+ const unique = [...new Set(files.map((item) => path.resolve(item)))].sort();
221
+ return Promise.all(unique.map(async (filePath) => {
222
+ const assetPath = scope === 'project'
223
+ ? path.relative(path.resolve(baseRoot), filePath).split(path.sep).join('/')
224
+ : filePath;
225
+ if (scope === 'project' && (!assetPath || assetPath.startsWith('..') || !within(baseRoot, filePath))) {
226
+ throw invalid(`managed project asset escaped control root: ${filePath}`);
227
+ }
228
+ return { scope, path: assetPath, kind: 'file', sha256: await sha256File(filePath) };
229
+ }));
230
+ }
231
+
232
+ export function buildInstallationManifest({
233
+ triadVersion,
234
+ adapter,
235
+ installedAt = new Date().toISOString(),
236
+ updatedAt = installedAt,
237
+ uninstalledAt,
238
+ scopes = { project: true, global: false },
239
+ scopeStatus = { project: 'installed', global: scopes.global ? 'installed' : 'not_configured' },
240
+ managedAssets = [],
241
+ status = 'installed'
242
+ }) {
243
+ const manifest = {
244
+ schema_version: INSTALLATION_MANIFEST_SCHEMA_VERSION,
245
+ triad_version: triadVersion,
246
+ adapter,
247
+ installed_at: installedAt,
248
+ updated_at: updatedAt,
249
+ scopes: { project: Boolean(scopes.project), global: Boolean(scopes.global) },
250
+ scope_status: { project: scopeStatus.project, global: scopeStatus.global },
251
+ managed_assets: [...managedAssets].sort((left, right) => `${left.scope}:${left.path}`.localeCompare(`${right.scope}:${right.path}`)),
252
+ status
253
+ };
254
+ if (uninstalledAt) manifest.uninstalled_at = uninstalledAt;
255
+ manifest.fingerprint = installationManifestFingerprint(manifest);
256
+ validateInstallationManifest(manifest);
257
+ return manifest;
258
+ }
259
+
260
+ export function manifestScopeStatus(manifest, scope) {
261
+ if (manifest.scope_status?.[scope]) return manifest.scope_status[scope];
262
+ return manifest.scopes?.[scope] ? 'installed' : 'not_configured';
263
+ }
264
+
265
+ export function manifestIsUninstalled(manifest) {
266
+ return manifest.status === 'uninstalled' || (
267
+ ['project', 'global'].every((scope) => ['not_configured', 'uninstalled'].includes(manifestScopeStatus(manifest, scope)))
268
+ );
269
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { writeCardReport, writeHandoffReport } from "./lib/human-reports.mjs";
6
+
7
+ function parseArgs(argv) {
8
+ const args = { mode: null, input: null, output: null, project: process.cwd(), worktree: null, baseCommit: null };
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const flag = argv[index];
11
+ if (flag === "--help" || flag === "-h") {
12
+ process.stdout.write("Usage: node .triad-runtime/triad-human-report.mjs --mode <card|handoff> --input <derived-report-context.json> --output <report.md> [--project <control-workspace>] [--worktree <product-worktree>] [--base-commit <card-baseline> ]\n");
13
+ return null;
14
+ }
15
+ const key = { "--mode": "mode", "--input": "input", "--output": "output", "--project": "project", "--worktree": "worktree", "--base-commit": "baseCommit" }[flag];
16
+ if (!key) throw Object.assign(new Error(`unknown option: ${flag}`), { code: "human_report_cli_invalid" });
17
+ const value = argv[index + 1];
18
+ if (!value || value.startsWith("--")) throw Object.assign(new Error(`${flag} requires a value`), { code: "human_report_cli_invalid" });
19
+ args[key] = value;
20
+ index += 1;
21
+ }
22
+ if (!args.mode || !["card", "handoff"].includes(args.mode)) throw Object.assign(new Error("--mode must be card or handoff"), { code: "human_report_cli_invalid" });
23
+ if (!args.input || !args.output) throw Object.assign(new Error("--input and --output are required"), { code: "human_report_cli_invalid" });
24
+ return args;
25
+ }
26
+
27
+ async function main() {
28
+ const args = parseArgs(process.argv.slice(2));
29
+ if (!args) return;
30
+ const projectRoot = await realpath(args.project);
31
+ const inputPath = path.resolve(projectRoot, args.input);
32
+ const outputPath = path.resolve(projectRoot, args.output);
33
+ const input = JSON.parse(await readFile(inputPath, "utf8"));
34
+ const result = args.mode === "card"
35
+ ? await writeCardReport(input, {
36
+ outputPath,
37
+ projectRoot,
38
+ worktree: args.worktree ? await realpath(path.resolve(projectRoot, args.worktree)) : input?.final?.worktree ?? null,
39
+ baseCommit: args.baseCommit ?? input?.final?.base_commit ?? null,
40
+ })
41
+ : await writeHandoffReport(input, { outputPath, projectRoot });
42
+ process.stdout.write(`${JSON.stringify({ valid: true, mode: args.mode, output: path.relative(projectRoot, result.outputPath) })}\n`);
43
+ }
44
+
45
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
46
+ main().catch((error) => {
47
+ process.stderr.write(`${error.code ? `${error.code}: ` : ""}${error.message}\n`);
48
+ process.exitCode = 2;
49
+ });
50
+ }
@@ -30,7 +30,10 @@ authority after the planning handoff.
30
30
  repositories, branches, and worktrees.
31
31
  3. Copy the approved PRD to `artifacts/prd.md`; record source, collection time,
32
32
  snapshot, revision when available, and SHA-256.
33
- 4. Copy `assets/loop-template/` to `.loop/`. For ordinary PRD-only input,
33
+ 4. Copy `assets/loop-template/` to `.loop/`. This includes derived report
34
+ templates under `runtime/report-context/`; keep report contexts and
35
+ `card-reports/` in the control workspace, never in product source. For
36
+ ordinary PRD-only input,
34
37
  create bounded feature cards under `features/` and a complete
35
38
  `feature-plan.md`. For native BMAD input, ingest the canonical Stories and
36
39
  materialize one normal Card per Story instead; do not perform a second
@@ -0,0 +1,32 @@
1
+ # Card report — <feature ID>: <title>
2
+
3
+ > Derived human-readable view of canonical Triad+ evidence. This report is not a new source of truth.
4
+
5
+ ## Result
6
+
7
+ `APPROVED | BLOCKED | NOT DELIVERED`
8
+
9
+ ## What was implemented
10
+
11
+ `<bounded outcome and actual result>`
12
+
13
+ ## Card-attributable changed paths
14
+
15
+ `<complete delta from the immutable card baseline, including additions, modifications, renames, and deletions>`
16
+
17
+ ## Verification and gates
18
+
19
+ `<verification runs, gate results, evidence references, and attempt/rework history>`
20
+
21
+ ## Independent Reviewer
22
+
23
+ `<decision, findings, resolutions, risks, and review evidence>`
24
+
25
+ ## Final evidence and provenance
26
+
27
+ `<repository, branch, commit/range, card baseline, candidate fingerprint, packet, review, and evidence references>`
28
+
29
+ ## Risks, deferred work, and terminal notes
30
+
31
+ `<truthful residual information; never claim success for a blocked card>`
32
+
@@ -1,5 +1,19 @@
1
1
  # Delivery handoff — <project ID>
2
2
 
3
+ ## Executive summary
4
+
5
+ `<human-first summary of the delivery decision, product outcome, and any blocked or deferred work>`
6
+
7
+ ## Card-by-card results
8
+
9
+ | Card | Result | Outcome | Commit | Candidate fingerprint | Evaluator+ | Human-readable report |
10
+ | --- | --- | --- | --- | --- | --- | --- |
11
+ | `<ID>` | `<approved|blocked|not delivered>` | `<summary>` | `<commit>` | `<fingerprint>` | `<report or not configured>` | `<relative card-reports/<ID>.md path>` |
12
+
13
+ The Markdown reports are derived views. The control-workspace run state,
14
+ verification evidence, review records, delivery gates, and optional Evaluator+
15
+ report remain authoritative.
16
+
3
17
  ## Decision
4
18
 
5
19
  `delivered | delivery_blocked | delivered_without_demo`
@@ -0,0 +1,28 @@
1
+ {
2
+ "schema_version": 1,
3
+ "project_id": "REPLACE_ME",
4
+ "card": {
5
+ "id": "REPLACE_ME",
6
+ "title": "REPLACE_ME",
7
+ "goal": "REPLACE_ME",
8
+ "outcome": "REPLACE_ME",
9
+ "target_repository": "REPLACE_ME",
10
+ "card_path": "features/REPLACE_ME.md"
11
+ },
12
+ "status": "approved",
13
+ "implementation": { "summary": "REPLACE_ME" },
14
+ "attempts": [],
15
+ "verification": [],
16
+ "review": null,
17
+ "final": {
18
+ "repository": "REPLACE_ME",
19
+ "branch": "REPLACE_ME",
20
+ "commit": "REPLACE_ME",
21
+ "base_commit": "REPLACE_ME",
22
+ "candidate_fingerprint": "REPLACE_ME",
23
+ "worktree": "REPLACE_ME_ABSOLUTE_PRODUCT_WORKTREE"
24
+ },
25
+ "evidence_refs": [],
26
+ "risks": [],
27
+ "deferred": []
28
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "schema_version": 1,
3
+ "project_id": "REPLACE_ME",
4
+ "decision": "delivered",
5
+ "executive_summary": "REPLACE_ME",
6
+ "cards": [],
7
+ "code_areas": [],
8
+ "residual": [],
9
+ "verification": "REPLACE_ME",
10
+ "review": "REPLACE_ME",
11
+ "branch_commits": [],
12
+ "practical_test": [],
13
+ "evaluator": "not configured",
14
+ "delivery": "REPLACE_ME",
15
+ "risks": []
16
+ }
@@ -204,6 +204,54 @@ fail-closed escalation; do not dispatch a Developer or consume retry budget.
204
204
  while a dependency-satisfied card remains `ready`; stop only for a declared
205
205
  escalation, a blocked card, or when every required card is terminal.
206
206
 
207
+ ## Human-readable terminal reports
208
+
209
+ Human-readable reports are derived views, not a second control plane and not a
210
+ new LLM task. For every terminal Card, assemble a small JSON context from the
211
+ canonical card, attempts, verifier evidence, Reviewer record, final commit, and
212
+ candidate fingerprint. Do not copy Developer prose as changed-path evidence.
213
+
214
+ For an approved Card, the context must include the final commit and fingerprint,
215
+ at least one current passing verifier record, an independent Reviewer approval,
216
+ and the complete Card-baseline commit. Materialize the report with the installed
217
+ runtime command, which collects the complete baseline delta from Git:
218
+
219
+ ```bash
220
+ node .triad-runtime/triad-human-report.mjs \
221
+ --mode card \
222
+ --project /absolute/path/to/control-workspace \
223
+ --input .loop/runtime/report-context/<card-id>.json \
224
+ --output card-reports/<card-id>.md \
225
+ --worktree /absolute/path/to/product-worktree \
226
+ --base-commit <card-baseline-commit>
227
+ ```
228
+
229
+ Generate this only after the Card reaches its terminal state. A blocked or
230
+ not-delivered Card must include a truthful reason and must never be rendered as
231
+ approved. The renderer is atomic and idempotent; a resumed run may repeat the
232
+ same command without creating duplicate reports. It records additions,
233
+ modifications, deletions, and renames from the Card baseline, including paths
234
+ left by earlier rework attempts. Keep report paths relative to the control
235
+ workspace and never emit machine-specific absolute paths.
236
+
237
+ After all required Cards and delivery criteria are closed, assemble a derived
238
+ handoff context and run:
239
+
240
+ ```bash
241
+ node .triad-runtime/triad-human-report.mjs \
242
+ --mode handoff \
243
+ --project /absolute/path/to/control-workspace \
244
+ --input .loop/runtime/report-context/delivery.json \
245
+ --output handoff.md
246
+ ```
247
+
248
+ The handoff must open with an executive summary and Card-by-Card report links,
249
+ then preserve the technical branch/commit map, verifier/review/evaluator
250
+ evidence, delivery criteria, demos, risks, and practical-test instructions.
251
+ Missing or invalid final evidence is a report-generation error, not a success
252
+ claim. Do not add a second report agent or overwrite an approved report with a
253
+ later non-terminal candidate.
254
+
207
255
  ## Unattended continuation rule
208
256
 
209
257
  The normal chain is unattended: Developer completion → verification → Reviewer