@smartmemory/compose 0.3.7 → 0.4.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/.claude/skills/compose/SKILL.md +12 -3
- package/.compose-deps.json +51 -25
- package/README.md +79 -7
- package/bin/compose.js +495 -360
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
- package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
- package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
- package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
- package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
- package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
- package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
- package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +9 -0
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +874 -170
- package/lib/canon-guard.js +28 -6
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +104 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +26 -13
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/deps.js +164 -7
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +3 -3
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +57 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -23
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +9 -1
- package/lib/judgment/store/records.js +1 -1
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +5 -1
- package/lib/judgment-writer.js +56 -2
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +52 -1
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +396 -199
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +271 -36
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +109 -23
- package/server/compose-mcp.js +88 -882
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +34 -2
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-PkZzHeMj.js +0 -894
- package/dist/assets/channel-qVK_qn4E.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
- package/dist/assets/clone-Pu3RyLUh.js +0 -1
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/mobile-BnXEOE3U.js +0 -17
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { execFile as defaultExecFile } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { access, lstat as defaultLstat, mkdtemp as defaultMkdtemp, readFile as defaultReadFile, rm as defaultRm, stat as defaultStat, writeFile as defaultWriteFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CUSTODY_TIMEOUT_MS, custodyBackend as defaultCustodyBackend, custodySign as defaultCustodySign,
|
|
11
|
+
GUARD_DIR, HINT_ENROL, PRIVATE_DIR, PUBKEY_PATH, SIGNER_PATH, SUDO_ENV,
|
|
12
|
+
SUDOERS_PATH, SUDO_LOCAL_PATH,
|
|
13
|
+
} from './guard-custody.js';
|
|
14
|
+
import { guardDescriptors as defaultGuardDescriptors } from '../server/stratum-client.js';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const sshsig = await import(require.resolve('@smartmemory/stratum/dist/guard/sshsig.js'));
|
|
18
|
+
const { parseAllowedSigners, sshFingerprint, verifySshsig } = sshsig;
|
|
19
|
+
const DESCRIPTOR_NAMESPACE = 'stratum-guard-descriptors';
|
|
20
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PACKAGED_SIGNER = path.resolve(MODULE_DIR, '..', 'scripts', 'guard-sign', 'compose-guard-sign.sh');
|
|
22
|
+
|
|
23
|
+
export const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
24
|
+
export const SUDOERS_TEXT = (principal) =>
|
|
25
|
+
`Defaults!${SIGNER_PATH} timestamp_timeout=0\n${principal} ALL=(root) ${SIGNER_PATH}\n`;
|
|
26
|
+
|
|
27
|
+
function spawnWithInput(execFile, file, args, options, bytes) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
let settled = false;
|
|
30
|
+
const done = (error, stdout = '', stderr = '') => {
|
|
31
|
+
if (settled) return;
|
|
32
|
+
settled = true;
|
|
33
|
+
resolve({ error, stdout, stderr });
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
const child = execFile(file, args, options, done);
|
|
37
|
+
child?.stdin?.end(bytes);
|
|
38
|
+
child?.on?.('error', (error) => done(error));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
done(error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function firstLine(value, fallback = 'command failed') {
|
|
46
|
+
return String(value || '').split(/\r?\n/, 1)[0] || fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Check every existing ancestor without following a symlink at any level. */
|
|
50
|
+
export async function validateAncestry(target, deps = {}) {
|
|
51
|
+
const lstat = deps.lstat ?? defaultLstat;
|
|
52
|
+
const stat = deps.stat ?? defaultStat;
|
|
53
|
+
let current = path.resolve(target);
|
|
54
|
+
for (;;) {
|
|
55
|
+
let lst;
|
|
56
|
+
try {
|
|
57
|
+
lst = await lstat(current);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error?.code === 'ENOENT') {
|
|
60
|
+
const parent = path.dirname(current);
|
|
61
|
+
if (parent === current) return { ok: false, offending: { path: current, reason: 'missing' } };
|
|
62
|
+
current = parent;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
return { ok: false, offending: { path: current, reason: firstLine(error?.message) } };
|
|
66
|
+
}
|
|
67
|
+
if (lst.isSymbolicLink()) return { ok: false, offending: { path: current, reason: 'symlink' } };
|
|
68
|
+
let info;
|
|
69
|
+
try { info = await stat(current); } catch (error) {
|
|
70
|
+
return { ok: false, offending: { path: current, reason: firstLine(error?.message) } };
|
|
71
|
+
}
|
|
72
|
+
if (info.uid !== 0) return { ok: false, offending: { path: current, reason: 'not root-owned' } };
|
|
73
|
+
if ((info.mode & 0o022) !== 0) return { ok: false, offending: { path: current, reason: 'group or world writable' } };
|
|
74
|
+
const parent = path.dirname(current);
|
|
75
|
+
if (parent === current) return { ok: true, offending: null };
|
|
76
|
+
current = parent;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolve the package's reviewable source trust root, never an environment path. */
|
|
81
|
+
export async function locateTrustRoot(deps = {}) {
|
|
82
|
+
const packageJson = deps.packageJsonPath ?? require.resolve('@smartmemory/stratum/package.json');
|
|
83
|
+
const packageRoot = deps.packageRoot ?? path.dirname(packageJson);
|
|
84
|
+
const exists = deps.exists ?? (async (file) => access(file).then(() => true, () => false));
|
|
85
|
+
const source = path.join(packageRoot, 'contracts', 'guard-signers.allowed');
|
|
86
|
+
if (await exists(source)) return { kind: 'source', path: source, checkoutRoot: packageRoot };
|
|
87
|
+
const dist = path.join(packageRoot, 'dist', 'contracts', 'guard-signers.allowed');
|
|
88
|
+
if (await exists(dist)) return { kind: 'dist', path: dist, checkoutRoot: null };
|
|
89
|
+
throw new Error(`could not locate Stratum guard trust root under ${packageRoot}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Produce a whole-file parsed trust-root candidate, never a partial append. */
|
|
93
|
+
export function candidateTrustRoot(current, principal, publicKeyLine) {
|
|
94
|
+
if (!PRINCIPAL_RE.test(principal)) throw new Error(`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`);
|
|
95
|
+
if (typeof publicKeyLine !== 'string' || /[^\x20-\x7e]/.test(publicKeyLine.trim())) {
|
|
96
|
+
throw new Error('public key line must be printable ASCII');
|
|
97
|
+
}
|
|
98
|
+
const key = parseAllowedSigners(`candidate ${publicKeyLine.trim()}\n`)[0];
|
|
99
|
+
if (!key) throw new Error('public key line did not contain a signer');
|
|
100
|
+
const signers = parseAllowedSigners(current);
|
|
101
|
+
const present = signers.some((entry) => entry.fingerprint === key.fingerprint);
|
|
102
|
+
const line = `${principal} ${publicKeyLine.trim()}`;
|
|
103
|
+
const text = present ? current : `${current}${current && !current.endsWith('\n') ? '\n' : ''}${line}\n`;
|
|
104
|
+
const parsed = parseAllowedSigners(text);
|
|
105
|
+
return { present, text, candidate: text, line, fingerprint: key.fingerprint, signers: parsed };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function installPlan(principal) {
|
|
109
|
+
if (!PRINCIPAL_RE.test(principal)) throw new Error(`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`);
|
|
110
|
+
return [
|
|
111
|
+
`install root-owned signer: ${SIGNER_PATH}`,
|
|
112
|
+
`create root-owned signing key: ${PRIVATE_DIR}/signing-key`,
|
|
113
|
+
`publish public key: ${PUBKEY_PATH}`,
|
|
114
|
+
`install sudoers rule: ${SUDOERS_PATH}`,
|
|
115
|
+
`enable Touch ID for sudo: ${SUDO_LOCAL_PATH}`,
|
|
116
|
+
`add signer principal to Stratum trust root: ${principal}`,
|
|
117
|
+
`root installation script:\n${installScript(principal)}`,
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SIGNER_HEREDOC_DELIMITER = 'COMPOSE_GUARD_SIGNER_SCRIPT';
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Exact root-side installation payload. It is intentionally shell-only and
|
|
125
|
+
* idempotent.
|
|
126
|
+
*
|
|
127
|
+
* Every `${…}` interpolated into this template is either a constant path
|
|
128
|
+
* exported by guard-custody.js (GUARD_DIR, PRIVATE_DIR, SIGNER_PATH,
|
|
129
|
+
* PUBKEY_PATH, SUDOERS_PATH, SUDO_LOCAL_PATH — never a runtime/user-supplied
|
|
130
|
+
* string) or shell text already embedded via a quoted heredoc (`sudoers`,
|
|
131
|
+
* `signerScript`) rather than interpolated inline. The packaged signer's
|
|
132
|
+
* module-directory path is never interpolated into the script at all: its
|
|
133
|
+
* bytes are read here and written to SIGNER_PATH via a quoted heredoc, so a
|
|
134
|
+
* compose install path containing a space or shell metacharacter cannot break
|
|
135
|
+
* or inject into the root shell.
|
|
136
|
+
*/
|
|
137
|
+
export function installScript(principal, deps = {}) {
|
|
138
|
+
if (!PRINCIPAL_RE.test(principal)) throw new Error(`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`);
|
|
139
|
+
const sudoers = SUDOERS_TEXT(principal).trimEnd();
|
|
140
|
+
const readSigner = deps.readSigner ?? (() => readFileSync(PACKAGED_SIGNER, 'utf8'));
|
|
141
|
+
const signerScript = readSigner();
|
|
142
|
+
if (signerScript.includes(SIGNER_HEREDOC_DELIMITER)) {
|
|
143
|
+
throw new Error(`packaged signer script contains the heredoc delimiter ${SIGNER_HEREDOC_DELIMITER}; choose a different delimiter`);
|
|
144
|
+
}
|
|
145
|
+
return `set -eu\numask 022\nPATH=/usr/bin:/bin:/usr/sbin\nexport PATH\n/bin/mkdir -p /Library/Compose/guard/private/tmp\n/bin/chmod 0755 /Library/Compose ${GUARD_DIR}\n/bin/chmod 0700 ${PRIVATE_DIR} ${PRIVATE_DIR}/tmp\n/bin/cat > ${SIGNER_PATH} <<'${SIGNER_HEREDOC_DELIMITER}'\n${signerScript.trimEnd()}\n${SIGNER_HEREDOC_DELIMITER}\n/usr/sbin/chown root:wheel ${SIGNER_PATH}\n/bin/chmod 0755 ${SIGNER_PATH}\nif [ ! -f ${PRIVATE_DIR}/signing-key ]; then\n /usr/bin/ssh-keygen -t ed25519 -N '' -q -f ${PRIVATE_DIR}/signing-key -C compose-guard\nfi\n/usr/sbin/chown root:wheel ${PRIVATE_DIR}/signing-key ${PRIVATE_DIR}/signing-key.pub\n/bin/chmod 0600 ${PRIVATE_DIR}/signing-key\n/bin/cp ${PRIVATE_DIR}/signing-key.pub ${PUBKEY_PATH}\n/usr/sbin/chown root:wheel ${PUBKEY_PATH}\n/bin/chmod 0644 ${PUBKEY_PATH}\n_sudoers_tmp=${SUDOERS_PATH}.tmp.$$\n/bin/cat > "$_sudoers_tmp" <<'COMPOSE_GUARD_SUDOERS'\n${sudoers}\nCOMPOSE_GUARD_SUDOERS\n/usr/sbin/visudo -cf "$_sudoers_tmp"\n/usr/bin/install -o root -g wheel -m 0440 "$_sudoers_tmp" ${SUDOERS_PATH}\n/bin/rm -f "$_sudoers_tmp"\nif [ ! -f ${SUDO_LOCAL_PATH} ]; then\n /bin/cat > ${SUDO_LOCAL_PATH} <<'COMPOSE_GUARD_PAM'\nauth sufficient pam_tid.so\nCOMPOSE_GUARD_PAM\nelif ! /usr/bin/grep -Eq '^[[:space:]]*auth[[:space:]]+sufficient[[:space:]]+pam_tid\\.so' ${SUDO_LOCAL_PATH}; then\n /bin/cat >> ${SUDO_LOCAL_PATH} <<'COMPOSE_GUARD_PAM'\nauth sufficient pam_tid.so\nCOMPOSE_GUARD_PAM\nfi\n/usr/sbin/chown root:wheel ${SUDO_LOCAL_PATH}\n/bin/chmod 0644 ${SUDO_LOCAL_PATH}\necho 'ok directories'\necho 'ok signer'\necho 'ok key'\necho 'ok sudoers'\necho 'ok pam_tid'\n`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Sign and cryptographically verify a durable fixture against exactly one public key. */
|
|
149
|
+
export async function roundTrip({ custodySign, publicKeyLine, deps = {} }) {
|
|
150
|
+
const mkdtemp = deps.mkdtemp ?? defaultMkdtemp;
|
|
151
|
+
const writeFile = deps.writeFile ?? defaultWriteFile;
|
|
152
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
153
|
+
const rm = deps.rm ?? defaultRm;
|
|
154
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'compose-guard-enrol-'));
|
|
155
|
+
const file = path.join(dir, 'descriptors.json');
|
|
156
|
+
const bytes = Buffer.from('{"version":1,"descriptors":[]}\n');
|
|
157
|
+
try {
|
|
158
|
+
await writeFile(file, bytes, { mode: 0o600 });
|
|
159
|
+
const signed = await custodySign({ bytes, namespace: DESCRIPTOR_NAMESPACE });
|
|
160
|
+
if (!signed?.ok) throw new Error(signed?.message || 'fixture signature was not approved');
|
|
161
|
+
await writeFile(`${file}.sig`, signed.armored, { mode: 0o600 });
|
|
162
|
+
const signer = parseAllowedSigners(`fixture ${publicKeyLine.trim()}\n`)[0];
|
|
163
|
+
if (!signer) throw new Error('public key line did not contain a signer');
|
|
164
|
+
verifySshsig(await readFile(file), await readFile(`${file}.sig`, 'utf8'), DESCRIPTOR_NAMESPACE, [signer.publicKey]);
|
|
165
|
+
return { dir, file, sig: `${file}.sig`, namespace: DESCRIPTOR_NAMESPACE, fingerprint: signer.fingerprint, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await rm(dir, { recursive: true, force: true });
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function rebuildStratumDist(checkoutRoot, deps = {}) {
|
|
173
|
+
const { error, stderr } = await spawnWithInput(deps.execFile ?? defaultExecFile, 'npm', ['run', 'build'], { cwd: checkoutRoot, timeout: CUSTODY_TIMEOUT_MS }, Buffer.alloc(0));
|
|
174
|
+
if (error) throw new Error(`Stratum build failed: ${firstLine(stderr || error.message)}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function result(status, lines, extra = {}) { return { status, lines, ...extra }; }
|
|
178
|
+
|
|
179
|
+
/** Complete, deliberately ordered enrolment ceremony. Every side effect is injectable. */
|
|
180
|
+
export async function runEnrol({ principal = process.env.USER || process.env.USERNAME || '' } = {}, deps = {}) {
|
|
181
|
+
// The plan must always reach a human before the root step runs — a silent
|
|
182
|
+
// no-op emitter would let any in-process caller (including a bare
|
|
183
|
+
// `runGuardEnrol()`) reach `sudo -k /bin/sh -s` with the plan never shown.
|
|
184
|
+
// So the emitter is mandatory, checked before the backend check, before any
|
|
185
|
+
// spawn or filesystem access.
|
|
186
|
+
if (typeof deps.emit !== 'function') {
|
|
187
|
+
return result('refused', ['enrol requires a line emitter so the plan is shown before the root step'], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
188
|
+
}
|
|
189
|
+
const backend = (deps.custodyBackend ?? defaultCustodyBackend)();
|
|
190
|
+
if (backend !== 'sudo') return result('refused', ['enrol requires the sudo custody backend on macOS'], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
191
|
+
if (!PRINCIPAL_RE.test(principal)) return result('refused', [`invalid principal ${JSON.stringify(principal)}; expected ${PRINCIPAL_RE}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
192
|
+
|
|
193
|
+
const ancestry = deps.validateAncestry ?? validateAncestry;
|
|
194
|
+
const installTargets = [GUARD_DIR, SIGNER_PATH, PUBKEY_PATH, PRIVATE_DIR, SUDOERS_PATH, SUDO_LOCAL_PATH];
|
|
195
|
+
for (const target of installTargets) {
|
|
196
|
+
const checked = await ancestry(target, deps);
|
|
197
|
+
if (!checked.ok) return result('refused', [`unsafe install ancestry at ${checked.offending.path}: ${checked.offending.reason}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
198
|
+
}
|
|
199
|
+
// `installScript` now reads the packaged signer from disk and can throw (a
|
|
200
|
+
// missing/unreadable signer, or a heredoc-delimiter collision); `emit` is
|
|
201
|
+
// caller-supplied and can throw too. Neither has spawned or written
|
|
202
|
+
// anything yet, so a failure here must still return the refused envelope,
|
|
203
|
+
// not reject the promise.
|
|
204
|
+
let script;
|
|
205
|
+
try {
|
|
206
|
+
script = installScript(principal, deps);
|
|
207
|
+
const emit = deps.emit;
|
|
208
|
+
for (const line of installPlan(principal)) emit(line);
|
|
209
|
+
emit('root installation follows:');
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return result('refused', [`enrol preparation failed: ${firstLine(error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
212
|
+
}
|
|
213
|
+
const lines = [];
|
|
214
|
+
const rootStep = await spawnWithInput(deps.execFile ?? defaultExecFile, '/usr/bin/sudo', ['-k', '/bin/sh', '-s'], { env: SUDO_ENV(), timeout: CUSTODY_TIMEOUT_MS, maxBuffer: 1 << 20 }, script);
|
|
215
|
+
if (rootStep.error) return result('refused', [...lines, `root installation failed: ${firstLine(rootStep.stderr || rootStep.error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
216
|
+
lines.push(...String(rootStep.stdout || '').split(/\r?\n/).filter((line) => line.startsWith('ok ')));
|
|
217
|
+
|
|
218
|
+
for (const target of installTargets) {
|
|
219
|
+
const checked = await ancestry(target, deps);
|
|
220
|
+
if (!checked.ok) return result('refused', [...lines, `unsafe installed ancestry at ${checked.offending.path}: ${checked.offending.reason}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
221
|
+
}
|
|
222
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
223
|
+
let publicKeyLine;
|
|
224
|
+
try { publicKeyLine = (await readFile(PUBKEY_PATH, 'utf8')).trim(); } catch (error) {
|
|
225
|
+
return result('refused', [...lines, `could not read enrolled public key: ${firstLine(error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let fixture;
|
|
229
|
+
try {
|
|
230
|
+
fixture = await roundTrip({ custodySign: deps.custodySign ?? defaultCustodySign, publicKeyLine, deps });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return result('refused', [...lines, `round-trip signature failed: ${firstLine(error.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const trust = await (deps.locateTrustRoot ?? locateTrustRoot)(deps);
|
|
236
|
+
const current = await readFile(trust.path, 'utf8');
|
|
237
|
+
const candidate = candidateTrustRoot(current, principal, publicKeyLine);
|
|
238
|
+
if (trust.kind !== 'source') {
|
|
239
|
+
return result('refused', [...lines, `add this line to ${trust.path} in a Stratum source release:`, candidate.line], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
240
|
+
}
|
|
241
|
+
if (!candidate.present) await (deps.writeFile ?? defaultWriteFile)(trust.path, candidate.text, 'utf8');
|
|
242
|
+
await (deps.rebuildStratumDist ?? rebuildStratumDist)(trust.checkoutRoot, deps);
|
|
243
|
+
const inspected = await (deps.guardDescriptors ?? defaultGuardDescriptors)(fixture.file);
|
|
244
|
+
const expected = `verified: signed by ${principal} (${fixture.fingerprint})`;
|
|
245
|
+
if (inspected?.status !== 'ok' || !String(inspected?.signature || '').startsWith(expected) || inspected?.group_or_world_writable !== false) {
|
|
246
|
+
return result('refused', [...lines, `final Stratum verification failed: ${inspected?.signature || 'no verifier verdict'}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
247
|
+
}
|
|
248
|
+
return result('done', [...lines, `enrolled ${principal} (${fixture.fingerprint})`, 'done'], { fingerprint: fixture.fingerprint });
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return result('refused', [...lines, `enrolment failed: ${firstLine(error?.message)}`], { code: 'upgrade_descriptor_unavailable', hint: HINT_ENROL });
|
|
251
|
+
} finally {
|
|
252
|
+
await fixture.cleanup();
|
|
253
|
+
}
|
|
254
|
+
}
|
package/lib/health-score.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Review findings from parallel_review's ReviewResult (canonical — STRAT-CLAUDE-EFFORT-PARITY)
|
|
7
7
|
* - Test pass/fail from coverage_check result
|
|
8
8
|
* - Contract compliance from Stratum ensure results
|
|
9
|
-
* - Doc freshness from lib/
|
|
9
|
+
* - Doc freshness from lib/lineage.js (findStaleArtifacts)
|
|
10
10
|
* - Plan completion from plan_completion ensure builtin
|
|
11
11
|
* - Runtime errors from build_step_done violations
|
|
12
12
|
*/
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ideabox-cli.js — `compose ideabox …`, writing to the record store.
|
|
3
|
+
*
|
|
4
|
+
* COMP-PLAN-IDEA-UNIFY S3b-1 (D18), rebased onto the shared operations in S3b-2
|
|
5
|
+
* (D20).
|
|
6
|
+
*
|
|
7
|
+
* Lifted out of a ~300-line inline `else if` block in `bin/compose.js` with no
|
|
8
|
+
* named symbol, in a 3,400-line file. The move is not cosmetic: it is what lets
|
|
9
|
+
* the cutover be driven in-process by a test instead of only through a
|
|
10
|
+
* subprocess, and the cutover is the part that needed testing most.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS MODULE IS NOW
|
|
13
|
+
* -----------------------
|
|
14
|
+
* Argument parsing, presentation, and exit codes. Nothing else. Every mutation
|
|
15
|
+
* lives in `fluid/ideabox-ops.js`, which the REST API calls too — including the
|
|
16
|
+
* two invariants that used to be spelled out here and would have had to be
|
|
17
|
+
* re-remembered by every new surface:
|
|
18
|
+
*
|
|
19
|
+
* 1. the migration gate runs before any write, and
|
|
20
|
+
* 2. the projection is rewritten only after the record is durable.
|
|
21
|
+
*
|
|
22
|
+
* A surface that does not implement an invariant cannot forget it. That is the
|
|
23
|
+
* whole reason the ops module exists; see its header for the failure it prevents.
|
|
24
|
+
*
|
|
25
|
+
* Behaviour is preserved deliberately, including output wording, exit codes and
|
|
26
|
+
* flag parsing, because a cutover that also redesigns the commands cannot be
|
|
27
|
+
* reviewed: any difference becomes ambiguous between "intended" and
|
|
28
|
+
* "regression". The exceptions are documented — `normalizeTags` in the ops
|
|
29
|
+
* module, the killed-idea promote refusal (D22), and the new `resurrect`
|
|
30
|
+
* subcommand (D23).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
ideaboxContext,
|
|
37
|
+
addIdea,
|
|
38
|
+
addDiscussion,
|
|
39
|
+
killIdea,
|
|
40
|
+
promoteIdea,
|
|
41
|
+
resurrectIdea,
|
|
42
|
+
setPriority,
|
|
43
|
+
updateIdea,
|
|
44
|
+
IdeaboxConflict,
|
|
45
|
+
IdeaboxInvalid,
|
|
46
|
+
IdeaboxNotFound,
|
|
47
|
+
} from './fluid/ideabox-ops.js';
|
|
48
|
+
import { toMarkdownDate } from './fluid/ideabox-dates.js';
|
|
49
|
+
import { KIND } from './fluid/provider.js';
|
|
50
|
+
import { ensureIdeaboxMigrated } from './fluid/ideabox-migrate.js';
|
|
51
|
+
import { writeIdeaboxProjection } from './fluid/render-ideabox.js';
|
|
52
|
+
|
|
53
|
+
const USAGE = [
|
|
54
|
+
'Usage: compose ideabox <subcommand>',
|
|
55
|
+
'',
|
|
56
|
+
'Subcommands:',
|
|
57
|
+
' add "<title>" Add a new idea',
|
|
58
|
+
' list List all ideas',
|
|
59
|
+
' promote <ID> Mark idea as PROMOTED (creates feature folder)',
|
|
60
|
+
' kill <ID> "<reason>" Move idea to Killed Ideas',
|
|
61
|
+
' resurrect <ID> Return a killed idea to the live set',
|
|
62
|
+
' pri <ID> <P0|P1|P2> Set priority',
|
|
63
|
+
' discuss <ID> "<comment>" Add a discussion comment',
|
|
64
|
+
' triage [--lens <name>] Walk untriaged ideas and assign priorities',
|
|
65
|
+
' render Rewrite the ideabox file from the records',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const PRIORITIES = ['P0', 'P1', 'P2'];
|
|
69
|
+
const flagValue = (args, name) => {
|
|
70
|
+
const i = args.indexOf(name);
|
|
71
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Status enum → the legacy display label the list output has always used. */
|
|
75
|
+
const displayStatus = (record) => {
|
|
76
|
+
if (record.status === 'promoted') return 'PROMOTED';
|
|
77
|
+
if (record.status === 'discussing') return 'DISCUSSING';
|
|
78
|
+
return 'NEW';
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const displayPriority = (record) => record.priority ?? '—';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Turn an op failure into the terminal's contract: a message on stderr and a
|
|
85
|
+
* non-zero exit. Only the ops' own typed failures are handled — anything else
|
|
86
|
+
* is a bug or a broken store, and swallowing those into a tidy exit code is how
|
|
87
|
+
* a corrupt project looks healthy.
|
|
88
|
+
*/
|
|
89
|
+
function reportOpFailure(err) {
|
|
90
|
+
if (err instanceof IdeaboxNotFound || err instanceof IdeaboxInvalid || err instanceof IdeaboxConflict) {
|
|
91
|
+
console.error(err.message);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} cwd project root
|
|
99
|
+
* @param {string[]} args argv after `ideabox`
|
|
100
|
+
* @param {object} [opts]
|
|
101
|
+
* @param {object} [opts.config] already-loaded `.compose/compose.json`
|
|
102
|
+
* @returns {Promise<number>} process exit code
|
|
103
|
+
*/
|
|
104
|
+
export async function runIdeaboxCommand(cwd, args, opts = {}) {
|
|
105
|
+
const sub = args[0];
|
|
106
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
107
|
+
for (const line of USAGE) console.log(line);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ctx = await ideaboxContext(cwd, { config: opts.config, origin: 'cli:ideabox' });
|
|
112
|
+
const { provider, ideaboxPath } = ctx;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
switch (sub) {
|
|
116
|
+
case 'add': {
|
|
117
|
+
const title = args.slice(1).find((a) => !a.startsWith('-')) || args[1];
|
|
118
|
+
if (!title) {
|
|
119
|
+
console.error('Usage: compose ideabox add "<title>" [--source "..."] [--desc "..."] [--cluster "..."]');
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const { record, createdCluster } = await addIdea(ctx, {
|
|
123
|
+
title,
|
|
124
|
+
body: flagValue(args, '--desc') ?? '',
|
|
125
|
+
source: flagValue(args, '--source') ?? '',
|
|
126
|
+
tags: flagValue(args, '--tags') ?? [],
|
|
127
|
+
cluster: flagValue(args, '--cluster') ?? null,
|
|
128
|
+
});
|
|
129
|
+
if (createdCluster) console.log(`Created cluster ${createdCluster.handle}: ${createdCluster.title}`);
|
|
130
|
+
console.log(`Added ${record.handle}: ${record.title}`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'list': {
|
|
135
|
+
// Read-only paths still migrate: `list` on an upgraded project must show
|
|
136
|
+
// the user's real ideas, not an empty store. It is the same import
|
|
137
|
+
// either way, and running it lazily on first read rather than first
|
|
138
|
+
// write means the upgrade happens at the least destructive moment
|
|
139
|
+
// available.
|
|
140
|
+
if (!existsSync(ideaboxPath) && !(await provider.listRecords({ kind: KIND.IDEA })).length) {
|
|
141
|
+
console.log('No ideabox found. Run: compose ideabox add "<title>"');
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
await ensureIdeaboxMigrated(provider, ideaboxPath);
|
|
145
|
+
const ideas = await provider.listRecords({ kind: KIND.IDEA });
|
|
146
|
+
if (ideas.length === 0) {
|
|
147
|
+
console.log('No ideas yet.');
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const live = ideas.filter((i) => i.status !== 'killed');
|
|
152
|
+
const killed = ideas.filter((i) => i.status === 'killed');
|
|
153
|
+
const order = { P0: 0, P1: 1, P2: 2, '—': 3 };
|
|
154
|
+
|
|
155
|
+
for (const status of ['NEW', 'DISCUSSING', 'PROMOTED']) {
|
|
156
|
+
const group = live.filter((i) => displayStatus(i) === status);
|
|
157
|
+
if (!group.length) continue;
|
|
158
|
+
group.sort((a, b) => (order[displayPriority(a)] ?? 3) - (order[displayPriority(b)] ?? 3));
|
|
159
|
+
console.log(`\n[${status}]`);
|
|
160
|
+
for (const idea of group) {
|
|
161
|
+
const pri = displayPriority(idea) !== '—' ? ` [${displayPriority(idea)}]` : '';
|
|
162
|
+
const tags = idea.tags.length ? ` ${idea.tags.join(' ')}` : '';
|
|
163
|
+
console.log(` ${idea.handle}${pri} ${idea.title}${tags}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (killed.length) {
|
|
168
|
+
console.log(`\n[KILLED] (${killed.length})`);
|
|
169
|
+
for (const idea of killed) {
|
|
170
|
+
console.log(` ${idea.handle} ${idea.title} — ${idea.killed?.reason ?? ''}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case 'promote': {
|
|
177
|
+
const id = args[1];
|
|
178
|
+
if (!id) {
|
|
179
|
+
console.error('Usage: compose ideabox promote <ID> [<FEATURE-CODE>]');
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
const { record, featureCode, featurePath, createdFeature } =
|
|
183
|
+
await promoteIdea(ctx, id, args[2] || '');
|
|
184
|
+
if (createdFeature) console.log(`Created feature folder: ${featurePath}/`);
|
|
185
|
+
console.log(`Promoted ${record.handle} → ${featureCode}`);
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
case 'kill': {
|
|
190
|
+
const id = args[1];
|
|
191
|
+
if (!id) {
|
|
192
|
+
console.error('Usage: compose ideabox kill <ID> "<reason>"');
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
const reason = args[2] || '';
|
|
196
|
+
const { record, alreadyKilled } = await killIdea(ctx, id, reason);
|
|
197
|
+
if (alreadyKilled) {
|
|
198
|
+
console.log(`${record.handle} was already killed on ${toMarkdownDate(record.killed?.at)}: ${record.killed?.reason ?? ''}`);
|
|
199
|
+
} else {
|
|
200
|
+
console.log(`Killed ${record.handle}: ${reason}`);
|
|
201
|
+
}
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'resurrect': {
|
|
206
|
+
const id = args[1];
|
|
207
|
+
if (!id) {
|
|
208
|
+
console.error('Usage: compose ideabox resurrect <ID>');
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
const { record } = await resurrectIdea(ctx, id);
|
|
212
|
+
console.log(`Resurrected ${record.handle}: ${record.title}`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'pri': {
|
|
217
|
+
const id = args[1];
|
|
218
|
+
const priority = args[2];
|
|
219
|
+
if (!id || !priority) {
|
|
220
|
+
console.error('Usage: compose ideabox pri <ID> <P0|P1|P2>');
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
if (!PRIORITIES.includes(priority.toUpperCase()) && priority !== '—') {
|
|
224
|
+
console.error(`Invalid priority: ${priority}. Use P0, P1, P2 or —`);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
const { record } = await setPriority(ctx, id, priority);
|
|
228
|
+
console.log(`Set ${record.handle} priority → ${priority}`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'discuss': {
|
|
233
|
+
const id = args[1];
|
|
234
|
+
const comment = args[2];
|
|
235
|
+
if (!id || !comment) {
|
|
236
|
+
console.error('Usage: compose ideabox discuss <ID> "<comment>"');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
await addDiscussion(ctx, id, { text: comment, author: 'human' });
|
|
240
|
+
console.log(`[${new Date().toISOString().slice(0, 10)}] human: ${comment}`);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'triage': {
|
|
245
|
+
const lensName = flagValue(args, '--lens');
|
|
246
|
+
await ensureIdeaboxMigrated(provider, ideaboxPath);
|
|
247
|
+
const ideas = await provider.listRecords({ kind: KIND.IDEA });
|
|
248
|
+
const untriaged = ideas.filter((i) => !i.priority && i.status === 'new');
|
|
249
|
+
if (!untriaged.length) {
|
|
250
|
+
console.log('No untriaged ideas.');
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (lensName) {
|
|
255
|
+
const { loadLens } = await import('./ideabox.js');
|
|
256
|
+
if (!loadLens(cwd, lensName)) {
|
|
257
|
+
console.warn(`Lens not found: docs/product/ideabox-priority-${lensName}.md`);
|
|
258
|
+
} else {
|
|
259
|
+
console.log(`Using lens: ${lensName}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const { createInterface } = await import('node:readline');
|
|
264
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
265
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
266
|
+
|
|
267
|
+
let changed = false;
|
|
268
|
+
try {
|
|
269
|
+
for (const idea of untriaged) {
|
|
270
|
+
console.log(`\n${idea.handle}: ${idea.title}`);
|
|
271
|
+
if (idea.body) console.log(` ${idea.body.slice(0, 120)}`);
|
|
272
|
+
if (lensName) console.log(` [lens: ${lensName}]`);
|
|
273
|
+
const answer = (await ask(' Priority [P0/P1/P2/skip]: ')).trim().toUpperCase();
|
|
274
|
+
if (PRIORITIES.includes(answer)) {
|
|
275
|
+
// The record write only. Triage is a bulk pass, so it renders once
|
|
276
|
+
// at the end rather than rewriting the whole file per answer.
|
|
277
|
+
await provider.updateRecord(idea.handle, { priority: answer });
|
|
278
|
+
changed = true;
|
|
279
|
+
console.log(` Set ${idea.handle} → ${answer}`);
|
|
280
|
+
} else {
|
|
281
|
+
console.log(' Skipped');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
rl.close();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (changed) {
|
|
289
|
+
await writeIdeaboxProjection(provider, ideaboxPath);
|
|
290
|
+
console.log('\nSaved.');
|
|
291
|
+
}
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'render': {
|
|
296
|
+
// The repair path. Every mutation writes the record before the
|
|
297
|
+
// projection, so a failed render leaves durable canon and a stale file;
|
|
298
|
+
// this is how the file catches up without touching a record. Also the
|
|
299
|
+
// fix for a projection that was interrupted, and the way back from any
|
|
300
|
+
// hand edit.
|
|
301
|
+
await ensureIdeaboxMigrated(provider, ideaboxPath);
|
|
302
|
+
await writeIdeaboxProjection(provider, ideaboxPath);
|
|
303
|
+
console.log(`Rendered ${ideaboxPath}`);
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
default:
|
|
308
|
+
console.error(`Unknown ideabox subcommand: ${sub}`);
|
|
309
|
+
console.error('Run: compose ideabox --help');
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
return reportOpFailure(err);
|
|
314
|
+
}
|
|
315
|
+
}
|