triad-plus 1.9.0 → 1.11.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/CHANGELOG.md +25 -0
- package/README.md +39 -0
- package/adapters/antigravity/README.md +1 -1
- package/adapters/antigravity/install.sh +1 -1
- package/adapters/claude-code/README.md +1 -1
- package/adapters/claude-code/install.sh +1 -1
- package/adapters/codex/README.md +1 -1
- package/adapters/codex/install.sh +3 -3
- package/adapters/hermes/install.sh +1 -1
- package/adapters/opencode/.opencode/agents/triad-developer.md +14 -0
- package/adapters/opencode/.opencode/agents/triad-orchestrator.md +1 -0
- package/adapters/opencode/.opencode/agents/triad-reviewer.md +15 -0
- package/adapters/opencode/README.md +15 -6
- package/adapters/opencode/install.sh +1 -1
- package/adapters/registry.mjs +22 -2
- package/bin/triad-plus.js +767 -56
- package/docs/configuration.md +57 -6
- package/docs/npx-installation.md +77 -4
- package/docs/opencode-replication.md +8 -0
- package/docs/runtimes.md +1 -1
- package/docs/troubleshooting.md +41 -0
- package/package.json +2 -2
- package/runtime/lib/assignment-packet.mjs +38 -1
- package/runtime/lib/installation-manifest.mjs +269 -0
- package/runtime/lib/model-config.mjs +165 -0
- package/runtime/lib/repository-context.mjs +192 -0
- package/runtime/lib/terminal.mjs +86 -0
- package/runtime/triad-runtime-context.mjs +88 -0
- package/runtime/triad-verify.mjs +26 -18
- package/skills/triad-loop-developer/SKILL.md +14 -0
- package/skills/triad-loop-orchestrator/SKILL.md +13 -0
- package/skills/triad-loop-reviewer/SKILL.md +16 -0
- package/skills/triad-model-configuration/SKILL.md +98 -0
- package/skills/triad-model-configuration/agents/openai.yaml +4 -0
package/docs/configuration.md
CHANGED
|
@@ -31,14 +31,65 @@ 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
|
+
|
|
65
|
+
## Configure models through the agent
|
|
66
|
+
|
|
67
|
+
The installed `triad-model-configuration` skill lets an owner ask the selected
|
|
68
|
+
agent to show or change role models without editing host-specific files. The
|
|
69
|
+
agent reads `.triad-runtime/adapter.json`, validates `.triad-plus/team.json`,
|
|
70
|
+
changes only the explicitly requested `model` or `reasoning_effort` fields, and
|
|
71
|
+
uses the existing managed binding path to materialize supported values. It
|
|
72
|
+
reports whether each request was applied host-natively, recorded only in the
|
|
73
|
+
team file, left at the host default, or unsupported. It never invents model IDs
|
|
74
|
+
and never puts credentials in the configuration.
|
|
75
|
+
|
|
76
|
+
The skill follows the adapter metadata: `global-profiles` writes native
|
|
77
|
+
user-level profiles, `project-frontmatter` writes only the declared native
|
|
78
|
+
fields (OpenCode supports model plus its native `variant`, Copilot supports
|
|
79
|
+
model plus `reasoningEffort`, and Claude Code currently supports model only),
|
|
80
|
+
and `team-record` records intent without fabricating a host binding. A null or
|
|
81
|
+
blank value deliberately means host default. Reasoning levels are host-specific
|
|
82
|
+
and are never translated between providers.
|
|
83
|
+
|
|
34
84
|
For project-frontmatter adapters, `.triad-plus/team.json` remains the canonical
|
|
35
85
|
source of truth. Managed installation and `upgrade --apply` re-materialize each
|
|
36
|
-
configured role's supported fields after refreshing agent assets. OpenCode
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
values are omitted so the host uses
|
|
40
|
-
expose different controls; Triad+ only
|
|
41
|
-
selected host.
|
|
86
|
+
configured role's supported fields after refreshing agent assets. OpenCode maps
|
|
87
|
+
`model` to `model` and `reasoning_effort` to its native `variant` field; Copilot
|
|
88
|
+
maps the same canonical fields to `model` and `reasoningEffort`; Claude Code
|
|
89
|
+
currently maps `model` only. Null or blank values are omitted so the host uses
|
|
90
|
+
its session default. Other adapters may expose different controls; Triad+ only
|
|
91
|
+
materializes fields supported by the selected host. A host/session default is
|
|
92
|
+
distinct from a role-agent binding.
|
|
42
93
|
|
|
43
94
|
## Retry and scope policy
|
|
44
95
|
|
package/docs/npx-installation.md
CHANGED
|
@@ -10,6 +10,10 @@ npx triad-plus
|
|
|
10
10
|
The interactive setup selects a host, optional user-level command, language,
|
|
11
11
|
owner address, role display names/personas, models, and whether the optional
|
|
12
12
|
Evaluator+ is enabled. It changes files only after `install` is typed.
|
|
13
|
+
Before confirmation it prints a compact summary of the host, control workspace,
|
|
14
|
+
interaction settings, Evaluator+, and each role's model/binding. Colors are
|
|
15
|
+
used only for an interactive terminal and are disabled by `NO_COLOR` or when
|
|
16
|
+
output is not a TTY.
|
|
13
17
|
|
|
14
18
|
For repeatable setup:
|
|
15
19
|
|
|
@@ -18,11 +22,68 @@ npx triad-plus init --host codex --control /path/to/project-control --global
|
|
|
18
22
|
npx triad-plus doctor --host codex --control /path/to/project-control
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
##
|
|
25
|
+
## Version visibility
|
|
22
26
|
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
26
87
|
|
|
27
88
|
```bash
|
|
28
89
|
npx triad-plus upgrade --host codex --control /path/to/project-control --global
|
|
@@ -46,6 +107,18 @@ personas, models, and supported effort/options. Existing schema-version-1 team
|
|
|
46
107
|
files remain valid. Core roles are always enabled; Evaluator+ is enabled only
|
|
47
108
|
when `roles.evaluator.enabled` is `true`.
|
|
48
109
|
|
|
110
|
+
The shared `triad-model-configuration` skill is installed with the selected
|
|
111
|
+
adapter. Ask the host agent to inspect or change a role model; it keeps
|
|
112
|
+
`.triad-plus/team.json` as the source of truth and re-materializes only fields
|
|
113
|
+
the adapter declares as host-native. Unsupported fields are reported rather
|
|
114
|
+
than silently substituted.
|
|
115
|
+
|
|
116
|
+
For OpenCode, the canonical `reasoning_effort` value is materialized as the
|
|
117
|
+
native per-agent `variant` field. Copilot uses its own native
|
|
118
|
+
`reasoningEffort` field; these host contracts are intentionally not inferred
|
|
119
|
+
from one another. The active host/session default remains separate from a
|
|
120
|
+
materialized role profile.
|
|
121
|
+
|
|
49
122
|
| Role | Responsibility |
|
|
50
123
|
| --- | --- |
|
|
51
124
|
| Orchestrator | Maintains goal/context and decides the next step. |
|
|
@@ -7,3 +7,11 @@ command assets carry the configured role models where OpenCode supports them.
|
|
|
7
7
|
Verification uses explicit Orchestrator dispatch. A configured Evaluator+ is
|
|
8
8
|
automatically invoked only after a Reviewer-approved result; it cannot reopen
|
|
9
9
|
that run.
|
|
10
|
+
|
|
11
|
+
`.triad-plus/team.json` is the desired source of truth for OpenCode role
|
|
12
|
+
configuration. Triad+ materializes each supported role's `model` and native
|
|
13
|
+
`variant` (from `reasoning_effort`) in `.opencode/agents/*.md` during init and
|
|
14
|
+
after `upgrade --apply`. A null value leaves that field absent so OpenCode uses
|
|
15
|
+
its host/session default. The OpenCode session default is independent of these
|
|
16
|
+
per-agent bindings; use `opencode models` to inspect available IDs and
|
|
17
|
+
variants.
|
package/docs/runtimes.md
CHANGED
|
@@ -49,6 +49,6 @@ validated independently as well.
|
|
|
49
49
|
## OpenCode
|
|
50
50
|
|
|
51
51
|
Use the interactive OpenCode TUI for complete multi-step Triad runs. OpenCode
|
|
52
|
-
1.18.
|
|
52
|
+
1.18.x validated the full Orchestrator → Developer → verifier → Reviewer →
|
|
53
53
|
configured Evaluator+ lifecycle in the TUI. `opencode run` is useful for
|
|
54
54
|
one-shot work but does not retain that multi-step parent/subagent lifecycle.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.11.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",
|
|
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",
|
|
32
32
|
"pack:check": "npm pack --dry-run"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
@@ -3,6 +3,7 @@ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { writeAtomicJson } from "./evidence.mjs";
|
|
5
5
|
import { worktreeBranch } from "./fingerprint.mjs";
|
|
6
|
+
import { inspectRepositoryContext } from "./repository-context.mjs";
|
|
6
7
|
|
|
7
8
|
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
8
9
|
const PACKET_START = "<!-- triad-plus-assignment-packet:start -->";
|
|
@@ -227,6 +228,19 @@ async function resolveProjectRepository(root, assignment, worktree, external) {
|
|
|
227
228
|
return matches.length === 1 ? matches[0].id : "declared-worktree";
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
async function repositoryMappingDetails(root, repository) {
|
|
232
|
+
if (!repository) return null;
|
|
233
|
+
const mapping = (await projectRepositoryMappings(root)).find((entry) => entry.id === repository);
|
|
234
|
+
if (!mapping) return null;
|
|
235
|
+
const declared = mapping.worktree || mapping.path;
|
|
236
|
+
return {
|
|
237
|
+
id: mapping.id,
|
|
238
|
+
path: mapping.path,
|
|
239
|
+
worktree: mapping.worktree,
|
|
240
|
+
resolved_worktree: declared ? await realpath(declaredPath(root, declared)).catch(() => null) : null
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
230
244
|
function section(source, names) {
|
|
231
245
|
const wanted = new Set(names.map((name) => name.toLowerCase()));
|
|
232
246
|
const lines = source.split("\n");
|
|
@@ -316,6 +330,21 @@ export async function resolveAssignmentContext(assignment, { projectRoot = proce
|
|
|
316
330
|
throw packetError("assignment_packet_invalid", `worktree branch does not match assignment: ${branch}`);
|
|
317
331
|
}
|
|
318
332
|
const repository = await resolveProjectRepository(root, assignment, worktree, external);
|
|
333
|
+
const repositoryMapping = await repositoryMappingDetails(root, repository);
|
|
334
|
+
const requiredSkills = Array.isArray(assignment.required_repository_skills) ? assignment.required_repository_skills : [];
|
|
335
|
+
let repositoryContext;
|
|
336
|
+
try {
|
|
337
|
+
repositoryContext = await inspectRepositoryContext({ worktree, requiredSkills });
|
|
338
|
+
} catch (error) {
|
|
339
|
+
error.repositoryContext = error.repositoryContext ?? null;
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
if (repositoryContext.status !== "pass") {
|
|
343
|
+
const details = repositoryContext.issues.map((item) => item.message).join("; ");
|
|
344
|
+
const error = packetError("repository_context_invalid", details || "assigned repository context is invalid");
|
|
345
|
+
error.repositoryContext = repositoryContext;
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
319
348
|
return {
|
|
320
349
|
projectRoot: root,
|
|
321
350
|
controlWorkspace: root,
|
|
@@ -324,11 +353,13 @@ export async function resolveAssignmentContext(assignment, { projectRoot = proce
|
|
|
324
353
|
external,
|
|
325
354
|
branch,
|
|
326
355
|
repository,
|
|
356
|
+
repositoryMapping,
|
|
357
|
+
repositoryContext,
|
|
327
358
|
declaredWorktree: assignment.worktree,
|
|
328
359
|
cardPath: assignment.card_path ? relativeProjectPath(root, assignment.card_path, "card path") : null,
|
|
329
360
|
prdPath: assignment.prd_path ? relativeProjectPath(root, assignment.prd_path, "PRD path") : null,
|
|
330
361
|
gatesPath: assignment.gates_path ? relativeProjectPath(root, assignment.gates_path, "gates path") : null,
|
|
331
|
-
requiredSkills
|
|
362
|
+
requiredSkills,
|
|
332
363
|
};
|
|
333
364
|
}
|
|
334
365
|
|
|
@@ -341,6 +372,7 @@ function packetMetadata(assignment, context, packetPath) {
|
|
|
341
372
|
feature_id: assignment.feature_id,
|
|
342
373
|
attempt: assignment.attempt,
|
|
343
374
|
repository: context.repository,
|
|
375
|
+
repository_mapping: context.repositoryMapping,
|
|
344
376
|
branch: context.branch,
|
|
345
377
|
worktree: context.declaredWorktree,
|
|
346
378
|
control_workspace: ".",
|
|
@@ -357,6 +389,11 @@ function packetMetadata(assignment, context, packetPath) {
|
|
|
357
389
|
},
|
|
358
390
|
required_gate_ids: Array.isArray(assignment.required_gate_ids) ? assignment.required_gate_ids : [],
|
|
359
391
|
mandatory_skills: context.requiredSkills.map((skill) => ({ path: skill.path, sha256: skill.sha256 })),
|
|
392
|
+
repository_context: {
|
|
393
|
+
assigned_worktree: context.repositoryContext.assigned_worktree,
|
|
394
|
+
assigned_git_top_level: context.repositoryContext.assigned_git_top_level,
|
|
395
|
+
skills: context.repositoryContext.skills
|
|
396
|
+
},
|
|
360
397
|
});
|
|
361
398
|
}
|
|
362
399
|
|
|
@@ -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
|
+
}
|