@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/brief/cli.d.ts +30 -0
- package/dist/brief/cli.d.ts.map +1 -0
- package/dist/brief/cli.js +85 -0
- package/dist/brief/collect.d.ts +17 -0
- package/dist/brief/collect.d.ts.map +1 -0
- package/dist/brief/collect.js +302 -0
- package/dist/brief/config.d.ts +40 -0
- package/dist/brief/config.d.ts.map +1 -0
- package/dist/brief/config.js +85 -0
- package/dist/brief/constants.d.ts +10 -0
- package/dist/brief/constants.d.ts.map +1 -0
- package/dist/brief/constants.js +9 -0
- package/dist/brief/docs.d.ts +53 -0
- package/dist/brief/docs.d.ts.map +1 -0
- package/dist/brief/docs.js +229 -0
- package/dist/brief/freshness.d.ts +104 -0
- package/dist/brief/freshness.d.ts.map +1 -0
- package/dist/brief/freshness.js +263 -0
- package/dist/brief/git-signals.d.ts +17 -0
- package/dist/brief/git-signals.d.ts.map +1 -0
- package/dist/brief/git-signals.js +101 -0
- package/dist/brief/index.d.ts +11 -0
- package/dist/brief/index.d.ts.map +1 -0
- package/dist/brief/index.js +5 -0
- package/dist/brief/reconcile.d.ts +75 -0
- package/dist/brief/reconcile.d.ts.map +1 -0
- package/dist/brief/reconcile.js +259 -0
- package/dist/brief/render.d.ts +60 -0
- package/dist/brief/render.d.ts.map +1 -0
- package/dist/brief/render.js +407 -0
- package/dist/brief/sanitize.d.ts +38 -0
- package/dist/brief/sanitize.d.ts.map +1 -0
- package/dist/brief/sanitize.js +48 -0
- package/dist/brief/selectors.d.ts +36 -0
- package/dist/brief/selectors.d.ts.map +1 -0
- package/dist/brief/selectors.js +75 -0
- package/dist/brief/story.d.ts +18 -0
- package/dist/brief/story.d.ts.map +1 -0
- package/dist/brief/story.js +59 -0
- package/dist/brief/types.d.ts +110 -0
- package/dist/brief/types.d.ts.map +1 -0
- package/dist/brief/types.js +11 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +166 -0
- package/dist/cli/init-args.d.ts +16 -0
- package/dist/cli/init-args.d.ts.map +1 -0
- package/dist/cli/init-args.js +24 -0
- package/dist/cli/ui-args.d.ts +23 -0
- package/dist/cli/ui-args.d.ts.map +1 -0
- package/dist/cli/ui-args.js +66 -0
- package/dist/cli/ui.d.ts +40 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +180 -0
- package/dist/coordinatorium/checkpoint.d.ts +44 -0
- package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
- package/dist/coordinatorium/checkpoint.js +124 -0
- package/dist/coordinatorium/cli.d.ts +39 -0
- package/dist/coordinatorium/cli.d.ts.map +1 -0
- package/dist/coordinatorium/cli.js +203 -0
- package/dist/coordinatorium/errors.d.ts +112 -0
- package/dist/coordinatorium/errors.d.ts.map +1 -0
- package/dist/coordinatorium/errors.js +145 -0
- package/dist/coordinatorium/index.d.ts +11 -0
- package/dist/coordinatorium/index.d.ts.map +1 -0
- package/dist/coordinatorium/index.js +5 -0
- package/dist/coordinatorium/lock.d.ts +187 -0
- package/dist/coordinatorium/lock.d.ts.map +1 -0
- package/dist/coordinatorium/lock.js +659 -0
- package/dist/coordinatorium/mission.d.ts +32 -0
- package/dist/coordinatorium/mission.d.ts.map +1 -0
- package/dist/coordinatorium/mission.js +87 -0
- package/dist/coordinatorium/state-keeper.d.ts +115 -0
- package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
- package/dist/coordinatorium/state-keeper.js +396 -0
- package/dist/coordinatorium/types.d.ts +69 -0
- package/dist/coordinatorium/types.d.ts.map +1 -0
- package/dist/coordinatorium/types.js +27 -0
- package/dist/durable/assess.d.ts +105 -0
- package/dist/durable/assess.d.ts.map +1 -0
- package/dist/durable/assess.js +239 -0
- package/dist/durable/canonical.d.ts +25 -0
- package/dist/durable/canonical.d.ts.map +1 -0
- package/dist/durable/canonical.js +78 -0
- package/dist/durable/cli.d.ts +37 -0
- package/dist/durable/cli.d.ts.map +1 -0
- package/dist/durable/cli.js +141 -0
- package/dist/durable/errors.d.ts +188 -0
- package/dist/durable/errors.d.ts.map +1 -0
- package/dist/durable/errors.js +317 -0
- package/dist/durable/fs-store.d.ts +233 -0
- package/dist/durable/fs-store.d.ts.map +1 -0
- package/dist/durable/fs-store.js +1393 -0
- package/dist/durable/index.d.ts +20 -0
- package/dist/durable/index.d.ts.map +1 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/log.d.ts +33 -0
- package/dist/durable/log.d.ts.map +1 -0
- package/dist/durable/log.js +157 -0
- package/dist/durable/memory-store.d.ts +51 -0
- package/dist/durable/memory-store.d.ts.map +1 -0
- package/dist/durable/memory-store.js +302 -0
- package/dist/durable/projection.d.ts +35 -0
- package/dist/durable/projection.d.ts.map +1 -0
- package/dist/durable/projection.js +328 -0
- package/dist/durable/prune-check.d.ts +17 -0
- package/dist/durable/prune-check.d.ts.map +1 -0
- package/dist/durable/prune-check.js +45 -0
- package/dist/durable/register.d.ts +100 -0
- package/dist/durable/register.d.ts.map +1 -0
- package/dist/durable/register.js +532 -0
- package/dist/durable/retention.d.ts +49 -0
- package/dist/durable/retention.d.ts.map +1 -0
- package/dist/durable/retention.js +59 -0
- package/dist/durable/runtime.d.ts +497 -0
- package/dist/durable/runtime.d.ts.map +1 -0
- package/dist/durable/runtime.js +1271 -0
- package/dist/durable/seams.d.ts +40 -0
- package/dist/durable/seams.d.ts.map +1 -0
- package/dist/durable/seams.js +21 -0
- package/dist/durable/testing.d.ts +11 -0
- package/dist/durable/testing.d.ts.map +1 -0
- package/dist/durable/testing.js +25 -0
- package/dist/durable/types.d.ts +442 -0
- package/dist/durable/types.d.ts.map +1 -0
- package/dist/durable/types.js +75 -0
- package/dist/graph/builder.d.ts +11 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/builder.js +153 -0
- package/dist/graph/identity.d.ts +37 -0
- package/dist/graph/identity.d.ts.map +1 -0
- package/dist/graph/identity.js +46 -0
- package/dist/graph/index.d.ts +7 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +3 -0
- package/dist/graph/resolver.d.ts +41 -0
- package/dist/graph/resolver.d.ts.map +1 -0
- package/dist/graph/resolver.js +36 -0
- package/dist/graph/types.d.ts +42 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +2 -0
- package/dist/http/server.d.ts +76 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +195 -0
- package/dist/http/ui-assets.d.ts +22 -0
- package/dist/http/ui-assets.d.ts.map +1 -0
- package/dist/http/ui-assets.js +100 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/init/index.d.ts +12 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +90 -0
- package/dist/init/prompts.d.ts +3 -0
- package/dist/init/prompts.d.ts.map +1 -0
- package/dist/init/prompts.js +47 -0
- package/dist/init/scaffold.d.ts +25 -0
- package/dist/init/scaffold.d.ts.map +1 -0
- package/dist/init/scaffold.js +204 -0
- package/dist/init/types.d.ts +14 -0
- package/dist/init/types.d.ts.map +1 -0
- package/dist/init/types.js +1 -0
- package/dist/readmodel/dashboard.d.ts +40 -0
- package/dist/readmodel/dashboard.d.ts.map +1 -0
- package/dist/readmodel/dashboard.js +236 -0
- package/dist/readmodel/index.d.ts +3 -0
- package/dist/readmodel/index.d.ts.map +1 -0
- package/dist/readmodel/index.js +1 -0
- package/dist/readmodel/types.d.ts +104 -0
- package/dist/readmodel/types.d.ts.map +1 -0
- package/dist/readmodel/types.js +15 -0
- package/dist/root/index.d.ts +179 -0
- package/dist/root/index.d.ts.map +1 -0
- package/dist/root/index.js +418 -0
- package/dist/root/safe-write.d.ts +268 -0
- package/dist/root/safe-write.d.ts.map +1 -0
- package/dist/root/safe-write.js +966 -0
- package/dist/root/write-artifacts.d.ts +20 -0
- package/dist/root/write-artifacts.d.ts.map +1 -0
- package/dist/root/write-artifacts.js +42 -0
- package/dist/scriptorium/compile.d.ts +4 -0
- package/dist/scriptorium/compile.d.ts.map +1 -0
- package/dist/scriptorium/compile.js +185 -0
- package/dist/scriptorium/frontmatter.d.ts +13 -0
- package/dist/scriptorium/frontmatter.d.ts.map +1 -0
- package/dist/scriptorium/frontmatter.js +90 -0
- package/dist/scriptorium/index.d.ts +3 -0
- package/dist/scriptorium/index.d.ts.map +1 -0
- package/dist/scriptorium/index.js +1 -0
- package/dist/scriptorium/markdown.d.ts +31 -0
- package/dist/scriptorium/markdown.d.ts.map +1 -0
- package/dist/scriptorium/markdown.js +88 -0
- package/dist/scriptorium/types.d.ts +78 -0
- package/dist/scriptorium/types.d.ts.map +1 -0
- package/dist/scriptorium/types.js +1 -0
- package/dist/types/enforcement.d.ts +11 -0
- package/dist/types/enforcement.d.ts.map +1 -0
- package/dist/types/enforcement.js +1 -0
- package/dist/types/integrity.d.ts +33 -0
- package/dist/types/integrity.d.ts.map +1 -0
- package/dist/types/integrity.js +1 -0
- package/dist/types/project-definition.d.ts +30 -0
- package/dist/types/project-definition.d.ts.map +1 -0
- package/dist/types/project-definition.js +1 -0
- package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
- package/dist/ui/assets/index-C4NhMaq7.js +28 -0
- package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
- package/dist/ui/index.html +13 -0
- package/dist/validate/index.d.ts +6 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +214 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +1 -0
- package/package.json +67 -4
- package/templates/.gitkeep +0 -0
- package/templates/default-instance/instruction/.cursorrules +53 -0
- package/templates/default-instance/instruction/.windsurfrules +53 -0
- package/templates/default-instance/instruction/CLAUDE.md +53 -0
- package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
- package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
- package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
- package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
- package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
- package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
- package/templates/default-instance/monolith/context/maps/routing-map.md +147 -0
- package/templates/default-instance/monolith/context/maps/semantic-map.md +73 -0
- package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
- package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
- package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
- package/templates/default-instance/monolith/governance/README.md +29 -0
- package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
- package/templates/default-instance/monolith/learning/INDEX.md +48 -0
- package/templates/default-instance/monolith/learning/injection-map.md +49 -0
- package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
- package/templates/default-instance/monolith/memory/errors/README.md +26 -0
- package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
- package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
- package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
- package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
- package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
- package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
- package/templates/default-instance/monolith/memory/working/active.md +41 -0
- package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
- package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
- package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
- package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
- package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
- package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
- package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
- package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
- package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
- package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
- package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
- package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
- package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
- package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
- package/templates/default-instance/monolith/skills/INDEX.md +28 -0
- package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
- package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
- package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
- package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
- package/templates/default-instance/monolith/state.json +11 -0
- package/templates/default-instance/monolith/tests/INDEX.md +57 -0
- package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
- package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
- package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
- package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
- package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
- package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
- package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
- package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
- package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
- package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
- package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
- package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
- package/templates/default-instance/projectroot/AGENTS.md +107 -0
- package/templates/default-instance/projectroot/monolith.config.json +32 -0
- package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tactical Coordinatorium — mission lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* A mission is a unit of development work tracked across AI sessions.
|
|
5
|
+
* This module owns mission semantics (ID generation, valid transitions);
|
|
6
|
+
* state-keeper.ts owns persistence and never knows what a mission is.
|
|
7
|
+
*/
|
|
8
|
+
import { type StateReadTarget, type StateWriteTarget } from './state-keeper.js';
|
|
9
|
+
import type { Mission, MissionResult } from './types.js';
|
|
10
|
+
export interface StartMissionInput {
|
|
11
|
+
title: string;
|
|
12
|
+
system: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Register a new mission as in_progress. ID generation happens inside the
|
|
16
|
+
* same locked critical section as the write, so concurrent `start` calls
|
|
17
|
+
* from independent processes can never collide on an ID.
|
|
18
|
+
*/
|
|
19
|
+
export declare function startMission(target: StateWriteTarget, input: StartMissionInput): Promise<Mission>;
|
|
20
|
+
export declare function listMissions(target: StateReadTarget): Promise<Mission[]>;
|
|
21
|
+
export declare function getMission(target: StateReadTarget, id: string): Promise<Mission>;
|
|
22
|
+
export interface FinishMissionInput {
|
|
23
|
+
result: MissionResult;
|
|
24
|
+
summary?: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Transition a mission from in_progress to a terminal state. Any other
|
|
28
|
+
* starting status (already completed/failed/cancelled) is rejected — once
|
|
29
|
+
* terminal, a mission's lifecycle is closed.
|
|
30
|
+
*/
|
|
31
|
+
export declare function finishMission(target: StateWriteTarget, id: string, input: FinishMissionInput): Promise<Mission>;
|
|
32
|
+
//# sourceMappingURL=mission.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mission.d.ts","sourceRoot":"","sources":["../../src/coordinatorium/mission.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAA0B,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAExG,OAAO,KAAK,EAAuB,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAc9E,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,gBAAgB,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAqBvG;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAG9E;AAED,wBAAsB,UAAU,CAAC,MAAM,EAAE,eAAe,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAKtF;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,aAAa,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC,OAAO,CAAC,CAsBlB"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tactical Coordinatorium — mission lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* A mission is a unit of development work tracked across AI sessions.
|
|
5
|
+
* This module owns mission semantics (ID generation, valid transitions);
|
|
6
|
+
* state-keeper.ts owns persistence and never knows what a mission is.
|
|
7
|
+
*/
|
|
8
|
+
import { mutateState, readState } from './state-keeper.js';
|
|
9
|
+
import { MissionNotFoundError, InvalidTransitionError, ValidationError } from './errors.js';
|
|
10
|
+
const MISSION_ID_RE = /^MIS-(\d+)$/;
|
|
11
|
+
const MISSION_RESULTS = ['completed', 'failed', 'cancelled'];
|
|
12
|
+
function nextMissionId(missions) {
|
|
13
|
+
let max = 0;
|
|
14
|
+
for (const id of Object.keys(missions)) {
|
|
15
|
+
const match = MISSION_ID_RE.exec(id);
|
|
16
|
+
if (match)
|
|
17
|
+
max = Math.max(max, Number(match[1]));
|
|
18
|
+
}
|
|
19
|
+
return `MIS-${String(max + 1).padStart(4, '0')}`;
|
|
20
|
+
}
|
|
21
|
+
function requireNonEmptyString(value, field) {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (trimmed.length === 0) {
|
|
24
|
+
throw new ValidationError(`"${field}" must be a non-empty string`);
|
|
25
|
+
}
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register a new mission as in_progress. ID generation happens inside the
|
|
30
|
+
* same locked critical section as the write, so concurrent `start` calls
|
|
31
|
+
* from independent processes can never collide on an ID.
|
|
32
|
+
*/
|
|
33
|
+
export async function startMission(target, input) {
|
|
34
|
+
const title = requireNonEmptyString(input.title, 'title');
|
|
35
|
+
const system = requireNonEmptyString(input.system, 'system');
|
|
36
|
+
const { result } = await mutateState(target, (draft) => {
|
|
37
|
+
const id = nextMissionId(draft.tactical.missions);
|
|
38
|
+
const now = new Date().toISOString();
|
|
39
|
+
const mission = {
|
|
40
|
+
id,
|
|
41
|
+
title,
|
|
42
|
+
system,
|
|
43
|
+
status: 'in_progress',
|
|
44
|
+
startedAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
summary: null,
|
|
47
|
+
};
|
|
48
|
+
draft.tactical.missions[id] = mission;
|
|
49
|
+
return mission;
|
|
50
|
+
});
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
export async function listMissions(target) {
|
|
54
|
+
const state = await readState(target);
|
|
55
|
+
return Object.values(state.tactical.missions).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
56
|
+
}
|
|
57
|
+
export async function getMission(target, id) {
|
|
58
|
+
const state = await readState(target);
|
|
59
|
+
const mission = state.tactical.missions[id];
|
|
60
|
+
if (!mission)
|
|
61
|
+
throw new MissionNotFoundError(id);
|
|
62
|
+
return mission;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Transition a mission from in_progress to a terminal state. Any other
|
|
66
|
+
* starting status (already completed/failed/cancelled) is rejected — once
|
|
67
|
+
* terminal, a mission's lifecycle is closed.
|
|
68
|
+
*/
|
|
69
|
+
export async function finishMission(target, id, input) {
|
|
70
|
+
if (!MISSION_RESULTS.includes(input.result)) {
|
|
71
|
+
throw new ValidationError(`"result" must be one of ${MISSION_RESULTS.join(', ')}, got "${String(input.result)}"`);
|
|
72
|
+
}
|
|
73
|
+
const { result } = await mutateState(target, (draft) => {
|
|
74
|
+
const mission = draft.tactical.missions[id];
|
|
75
|
+
if (!mission)
|
|
76
|
+
throw new MissionNotFoundError(id);
|
|
77
|
+
if (mission.status !== 'in_progress') {
|
|
78
|
+
throw new InvalidTransitionError(`Mission "${id}" is already "${mission.status}" and cannot transition to "${input.result}"`);
|
|
79
|
+
}
|
|
80
|
+
mission.status = input.result;
|
|
81
|
+
mission.updatedAt = new Date().toISOString();
|
|
82
|
+
if (input.summary !== undefined)
|
|
83
|
+
mission.summary = input.summary;
|
|
84
|
+
return mission;
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Keeper — the single point of read/write access to state.json.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees:
|
|
5
|
+
* - Atomic persistence: writes land via write-temp-then-rename, so a reader
|
|
6
|
+
* never observes a partially written file.
|
|
7
|
+
* - No lost updates: every mutation runs inside a cross-process lock (see
|
|
8
|
+
* lock.ts), read-modify-write, with a monotonic revision bump.
|
|
9
|
+
* - Never silently overwrites corrupted or unsupported state: malformed
|
|
10
|
+
* JSON or a schema mismatch throws before anything is written.
|
|
11
|
+
*
|
|
12
|
+
* AI reports state; this module validates and persists it. It has no
|
|
13
|
+
* knowledge of what a "mission" is — that belongs to mission.ts.
|
|
14
|
+
*/
|
|
15
|
+
import { type LockOptions } from './lock.js';
|
|
16
|
+
import { type WriteWarning } from '../root/index.js';
|
|
17
|
+
import { type CoordinatoriumState } from './types.js';
|
|
18
|
+
export declare const STATE_FILE_NAME = "state.json";
|
|
19
|
+
/**
|
|
20
|
+
* Protective ceiling for `state.json` (SEC-001B.1) — NOT a retention policy.
|
|
21
|
+
* Append-only checkpoints grow the file without bound; see STATE-001.
|
|
22
|
+
* Enforced on read (bounded reader) and before an atomic write.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MAX_STATE_BYTES: number;
|
|
25
|
+
export declare function stateFilePath(stateDir: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Where a write may land (SEC-001B.2, ADR-012). There is no default and no
|
|
28
|
+
* plain-string form: the caller states which trust model applies.
|
|
29
|
+
*
|
|
30
|
+
* - `project`: `projectRoot` is the boundary. `stateDir` must lie inside it and
|
|
31
|
+
* no component below the root may be a symlink (even one that stays inside).
|
|
32
|
+
* - `trusted-directory`: the caller asserts that `stateDir` AND ALL ITS
|
|
33
|
+
* ANCESTORS are trusted. Nothing about the ancestors is checked (so a
|
|
34
|
+
* symlinked `.monolith` is invisible in this mode); only what lies below
|
|
35
|
+
* `stateDir` is. A missing `stateDir` is created.
|
|
36
|
+
*
|
|
37
|
+
* `onWarning` receives non-fatal findings reported after a successful publish
|
|
38
|
+
* (durability downgrade, platform note, ...). It is called once per warning,
|
|
39
|
+
* after the state lock has been released; an exception it throws is ignored and
|
|
40
|
+
* never affects the result. Without it they are only returned by `mutateState`.
|
|
41
|
+
*/
|
|
42
|
+
export type StateWriteTarget = {
|
|
43
|
+
scope: 'project';
|
|
44
|
+
projectRoot: string;
|
|
45
|
+
stateDir: string;
|
|
46
|
+
onWarning?: (warning: WriteWarning) => void;
|
|
47
|
+
} | {
|
|
48
|
+
scope: 'trusted-directory';
|
|
49
|
+
stateDir: string;
|
|
50
|
+
onWarning?: (warning: WriteWarning) => void;
|
|
51
|
+
};
|
|
52
|
+
/** Project-scoped write target (the CLI always uses this). */
|
|
53
|
+
export declare function projectTarget(projectRoot: string, stateDir: string, onWarning?: (warning: WriteWarning) => void): StateWriteTarget;
|
|
54
|
+
/** Trusted-directory write target: `stateDir` and its ancestors are asserted trusted by the caller. */
|
|
55
|
+
export declare function trustedDirectoryTarget(stateDir: string, onWarning?: (warning: WriteWarning) => void): StateWriteTarget;
|
|
56
|
+
/**
|
|
57
|
+
* Where a read may look (SEC-001B.1-R). Same shape and same scopes as a write
|
|
58
|
+
* target, minus `onWarning`; there is no default and no plain-string form. A
|
|
59
|
+
* `StateWriteTarget` is accepted as is.
|
|
60
|
+
*/
|
|
61
|
+
export type StateReadTarget = {
|
|
62
|
+
scope: 'project';
|
|
63
|
+
projectRoot: string;
|
|
64
|
+
stateDir: string;
|
|
65
|
+
} | {
|
|
66
|
+
scope: 'trusted-directory';
|
|
67
|
+
stateDir: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Validate the shape of parsed JSON and return a typed, defensively-copied
|
|
71
|
+
* CoordinatoriumState. Throws StateSchemaError for anything that does not
|
|
72
|
+
* match a supported schema version — this function never "fixes" or
|
|
73
|
+
* partially accepts malformed state.
|
|
74
|
+
*
|
|
75
|
+
* Supports exactly two on-disk shapes:
|
|
76
|
+
* - `schemaVersion: 2` (current) — `tactical.checkpoints` is required.
|
|
77
|
+
* - `schemaVersion: 1` (legacy, pre-COORDINATORIUM-002) — migrated
|
|
78
|
+
* in-memory to v2 on read (see `migrateV1ToV2`). Anything else
|
|
79
|
+
* (including a *newer* version this binary doesn't know about) is
|
|
80
|
+
* rejected outright — this is the deliberate fix for the compatibility
|
|
81
|
+
* gap an independent review found in the first COORDINATORIUM-002 cut:
|
|
82
|
+
* that cut kept `schemaVersion` at 1 while adding `tactical.checkpoints`,
|
|
83
|
+
* so an *older* binary (hardcoded to `schemaVersion === 1`, with no
|
|
84
|
+
* notion of checkpoints) could read a checkpoint-bearing v1-tagged file,
|
|
85
|
+
* reconstruct state from only the fields it knew about (`tactical: {
|
|
86
|
+
* missions }`, dropping checkpoints entirely — this function has never
|
|
87
|
+
* been a passthrough for unknown fields), and silently overwrite the
|
|
88
|
+
* file on its next mutateState call, destroying checkpoint history with
|
|
89
|
+
* no error raised. Bumping to 2 converts that silent data loss into a
|
|
90
|
+
* loud, immediate `StateSchemaError` for any reader that doesn't
|
|
91
|
+
* understand v2 — the version check below runs before anything is ever
|
|
92
|
+
* read into a mutable draft, so mutateState never reaches its write step
|
|
93
|
+
* (same proven pattern as the existing corrupted-JSON guard).
|
|
94
|
+
*/
|
|
95
|
+
export declare function validateState(raw: unknown): CoordinatoriumState;
|
|
96
|
+
/**
|
|
97
|
+
* Read current state without taking a lock. If the file does not exist,
|
|
98
|
+
* returns a fresh initial state WITHOUT persisting it — read paths never
|
|
99
|
+
* have the side effect of creating state.json. Corrupted JSON or an
|
|
100
|
+
* unsupported schema throws rather than being silently replaced.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readState(target: StateReadTarget): Promise<CoordinatoriumState>;
|
|
103
|
+
/**
|
|
104
|
+
* Read-modify-write under an exclusive cross-process lock. `mutator`
|
|
105
|
+
* receives a deep-cloned draft of the current state and an `applyResult`
|
|
106
|
+
* callback to hand back an arbitrary value (e.g. the mission it just
|
|
107
|
+
* created) alongside the write. The revision is incremented exactly once
|
|
108
|
+
* per successful call, after the mutator returns.
|
|
109
|
+
*/
|
|
110
|
+
export declare function mutateState<T>(target: StateWriteTarget, mutator: (draft: CoordinatoriumState) => T, lockOptions?: LockOptions): Promise<{
|
|
111
|
+
state: CoordinatoriumState;
|
|
112
|
+
result: T;
|
|
113
|
+
warnings: WriteWarning[];
|
|
114
|
+
}>;
|
|
115
|
+
//# sourceMappingURL=state-keeper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state-keeper.d.ts","sourceRoot":"","sources":["../../src/coordinatorium/state-keeper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAE1D,OAAO,EASL,KAAK,YAAY,EAClB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAIL,KAAK,mBAAmB,EAIzB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,eAAe,eAAe,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAmB,CAAC;AAEhD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD;AAMD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CAAA;CAAE,GACxG;IAAE,KAAK,EAAE,mBAAmB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CAAA;CAAE,CAAC;AAElG,8DAA8D;AAC9D,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,GAC1C,gBAAgB,CAElB;AAED,uGAAuG;AACvG,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,GAAG,gBAAgB,CAEtH;AAwCD;;;;GAIG;AACH,MAAM,MAAM,eAAe,GACvB;IAAE,KAAK,EAAE,SAAS,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC3D;IAAE,KAAK,EAAE,mBAAmB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AA8FrD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,mBAAmB,CAmE/D;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqCrF;AA0ED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,CAAC,EACjC,MAAM,EAAE,gBAAgB,EACxB,OAAO,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,CAAC,EAC1C,WAAW,CAAC,EAAE,WAAW,GACxB,OAAO,CAAC;IAAE,KAAK,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,CAAC,CAAC;IAAC,QAAQ,EAAE,YAAY,EAAE,CAAA;CAAE,CAAC,CA0B9E"}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Keeper — the single point of read/write access to state.json.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees:
|
|
5
|
+
* - Atomic persistence: writes land via write-temp-then-rename, so a reader
|
|
6
|
+
* never observes a partially written file.
|
|
7
|
+
* - No lost updates: every mutation runs inside a cross-process lock (see
|
|
8
|
+
* lock.ts), read-modify-write, with a monotonic revision bump.
|
|
9
|
+
* - Never silently overwrites corrupted or unsupported state: malformed
|
|
10
|
+
* JSON or a schema mismatch throws before anything is written.
|
|
11
|
+
*
|
|
12
|
+
* AI reports state; this module validates and persists it. It has no
|
|
13
|
+
* knowledge of what a "mission" is — that belongs to mission.ts.
|
|
14
|
+
*/
|
|
15
|
+
import { lstatSync, mkdirSync } from 'fs';
|
|
16
|
+
import { isAbsolute, join } from 'path';
|
|
17
|
+
import { acquireLock } from './lock.js';
|
|
18
|
+
import { StateCorruptedError, StateSchemaError, StateFileError } from './errors.js';
|
|
19
|
+
import { SafeWriteError, openParent, projectBoundary, readBoundedFileStable, replaceFile, trustedDirectoryBoundary, } from '../root/index.js';
|
|
20
|
+
import { STATE_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION_V1, createInitialState, } from './types.js';
|
|
21
|
+
export const STATE_FILE_NAME = 'state.json';
|
|
22
|
+
/**
|
|
23
|
+
* Protective ceiling for `state.json` (SEC-001B.1) — NOT a retention policy.
|
|
24
|
+
* Append-only checkpoints grow the file without bound; see STATE-001.
|
|
25
|
+
* Enforced on read (bounded reader) and before an atomic write.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_STATE_BYTES = 16 * 1024 * 1024;
|
|
28
|
+
export function stateFilePath(stateDir) {
|
|
29
|
+
return join(stateDir, STATE_FILE_NAME);
|
|
30
|
+
}
|
|
31
|
+
function lockFilePath(stateDir) {
|
|
32
|
+
return `${stateFilePath(stateDir)}.lock`;
|
|
33
|
+
}
|
|
34
|
+
/** Project-scoped write target (the CLI always uses this). */
|
|
35
|
+
export function projectTarget(projectRoot, stateDir, onWarning) {
|
|
36
|
+
return { scope: 'project', projectRoot, stateDir, onWarning };
|
|
37
|
+
}
|
|
38
|
+
/** Trusted-directory write target: `stateDir` and its ancestors are asserted trusted by the caller. */
|
|
39
|
+
export function trustedDirectoryTarget(stateDir, onWarning) {
|
|
40
|
+
return { scope: 'trusted-directory', stateDir, onWarning };
|
|
41
|
+
}
|
|
42
|
+
function scopeRequired(fn, stateDir) {
|
|
43
|
+
return new SafeWriteError(`${fn} requires an explicit write scope. Replace ${fn}(dir, ...) with ` +
|
|
44
|
+
`${fn}({ scope: 'trusted-directory', stateDir: dir }, ...) when dir and its ancestors are trusted, or with ` +
|
|
45
|
+
`${fn}({ scope: 'project', projectRoot, stateDir }, ...) when acting on a project (ADR-012).`, {
|
|
46
|
+
code: 'WRITE_SCOPE_REQUIRED',
|
|
47
|
+
phase: 'scope',
|
|
48
|
+
publication: 'NOT_PUBLISHED',
|
|
49
|
+
path: typeof stateDir === 'string' ? stateDir : '',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Validates the scope and builds the boundary. Throws SafeWriteError; touches nothing in `project` scope. */
|
|
53
|
+
function resolveWriteTarget(target, fn) {
|
|
54
|
+
if (typeof target !== 'object' || target === null)
|
|
55
|
+
throw scopeRequired(fn, target);
|
|
56
|
+
const t = target;
|
|
57
|
+
if (typeof t.stateDir !== 'string' || t.stateDir.length === 0)
|
|
58
|
+
throw scopeRequired(fn, t.stateDir);
|
|
59
|
+
if (t.scope === 'project') {
|
|
60
|
+
if (typeof t.projectRoot !== 'string' || t.projectRoot.length === 0)
|
|
61
|
+
throw scopeRequired(fn, t.stateDir);
|
|
62
|
+
return { boundary: projectBoundary(t.projectRoot), stateDir: t.stateDir, onWarning: t.onWarning };
|
|
63
|
+
}
|
|
64
|
+
if (t.scope === 'trusted-directory') {
|
|
65
|
+
// The caller vouches for the directory and its ancestors, so creating it
|
|
66
|
+
// (and any missing ancestors) is within the model they chose.
|
|
67
|
+
mkdirSync(t.stateDir, { recursive: true });
|
|
68
|
+
return { boundary: trustedDirectoryBoundary(t.stateDir), stateDir: t.stateDir, onWarning: t.onWarning };
|
|
69
|
+
}
|
|
70
|
+
throw scopeRequired(fn, t.stateDir);
|
|
71
|
+
}
|
|
72
|
+
/** Validates the read scope; returns the boundary directory. Throws StateFileError, touches nothing. */
|
|
73
|
+
function resolveReadTarget(target, fn) {
|
|
74
|
+
const bad = (why) => new StateFileError('READ_BOUNDARY_INVALID', `${fn} requires an explicit read scope (${why}). Use { scope: 'project', projectRoot, stateDir } for a project, ` +
|
|
75
|
+
`or { scope: 'trusted-directory', stateDir } when stateDir is trusted (SEC-001B.1-R).`);
|
|
76
|
+
if (typeof target !== 'object' || target === null)
|
|
77
|
+
throw bad('got a non-object');
|
|
78
|
+
const t = target;
|
|
79
|
+
// Every path is validated here, before any filesystem call: a path that cannot be
|
|
80
|
+
// trusted must never be probed (and so never "not found" -> empty state) (L-2).
|
|
81
|
+
// The cwd is never a boundary, so a relative path is refused, not resolved.
|
|
82
|
+
if (typeof t.stateDir !== 'string' || t.stateDir.length === 0)
|
|
83
|
+
throw bad('stateDir is missing');
|
|
84
|
+
if (!isAbsolute(t.stateDir))
|
|
85
|
+
throw bad('stateDir must be an absolute path');
|
|
86
|
+
if (t.scope === 'project') {
|
|
87
|
+
if (typeof t.projectRoot !== 'string' || t.projectRoot.length === 0)
|
|
88
|
+
throw bad('projectRoot is missing');
|
|
89
|
+
if (!isAbsolute(t.projectRoot))
|
|
90
|
+
throw bad('projectRoot must be an absolute path');
|
|
91
|
+
return { boundary: t.projectRoot, stateDir: t.stateDir };
|
|
92
|
+
}
|
|
93
|
+
if (t.scope === 'trusted-directory')
|
|
94
|
+
return { boundary: t.stateDir, stateDir: t.stateDir };
|
|
95
|
+
throw bad('unknown scope');
|
|
96
|
+
}
|
|
97
|
+
const MISSION_STATUSES = ['in_progress', 'completed', 'failed', 'cancelled'];
|
|
98
|
+
function isPlainObject(v) {
|
|
99
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
100
|
+
}
|
|
101
|
+
function validateMission(id, raw) {
|
|
102
|
+
if (!isPlainObject(raw)) {
|
|
103
|
+
throw new StateSchemaError(`Mission "${id}" is not an object`);
|
|
104
|
+
}
|
|
105
|
+
const { title, system, status, startedAt, updatedAt, summary } = raw;
|
|
106
|
+
if (raw.id !== id) {
|
|
107
|
+
throw new StateSchemaError(`Mission "${id}" has mismatched id field "${String(raw.id)}"`);
|
|
108
|
+
}
|
|
109
|
+
if (typeof title !== 'string' || title.length === 0) {
|
|
110
|
+
throw new StateSchemaError(`Mission "${id}" has an invalid title`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof system !== 'string' || system.length === 0) {
|
|
113
|
+
throw new StateSchemaError(`Mission "${id}" has an invalid system`);
|
|
114
|
+
}
|
|
115
|
+
if (typeof status !== 'string' || !MISSION_STATUSES.includes(status)) {
|
|
116
|
+
throw new StateSchemaError(`Mission "${id}" has an invalid status "${String(status)}"`);
|
|
117
|
+
}
|
|
118
|
+
if (typeof startedAt !== 'string') {
|
|
119
|
+
throw new StateSchemaError(`Mission "${id}" has an invalid startedAt`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof updatedAt !== 'string') {
|
|
122
|
+
throw new StateSchemaError(`Mission "${id}" has an invalid updatedAt`);
|
|
123
|
+
}
|
|
124
|
+
if (summary !== null && typeof summary !== 'string') {
|
|
125
|
+
throw new StateSchemaError(`Mission "${id}" has an invalid summary`);
|
|
126
|
+
}
|
|
127
|
+
return { id, title, system, status: status, startedAt, updatedAt, summary };
|
|
128
|
+
}
|
|
129
|
+
function validateCheckpoint(id, raw) {
|
|
130
|
+
if (!isPlainObject(raw)) {
|
|
131
|
+
throw new StateSchemaError(`Checkpoint "${id}" is not an object`);
|
|
132
|
+
}
|
|
133
|
+
const { missionId, createdAt, summary, remainingSteps, context } = raw;
|
|
134
|
+
if (raw.id !== id) {
|
|
135
|
+
throw new StateSchemaError(`Checkpoint "${id}" has mismatched id field "${String(raw.id)}"`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof missionId !== 'string' || missionId.length === 0) {
|
|
138
|
+
throw new StateSchemaError(`Checkpoint "${id}" has an invalid missionId`);
|
|
139
|
+
}
|
|
140
|
+
if (typeof createdAt !== 'string') {
|
|
141
|
+
throw new StateSchemaError(`Checkpoint "${id}" has an invalid createdAt`);
|
|
142
|
+
}
|
|
143
|
+
if (typeof summary !== 'string' || summary.length === 0) {
|
|
144
|
+
throw new StateSchemaError(`Checkpoint "${id}" has an invalid summary`);
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(remainingSteps) || !remainingSteps.every((s) => typeof s === 'string' && s.length > 0)) {
|
|
147
|
+
throw new StateSchemaError(`Checkpoint "${id}" has an invalid remainingSteps`);
|
|
148
|
+
}
|
|
149
|
+
if (context !== null && typeof context !== 'string') {
|
|
150
|
+
throw new StateSchemaError(`Checkpoint "${id}" has an invalid context`);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
missionId,
|
|
155
|
+
createdAt,
|
|
156
|
+
summary,
|
|
157
|
+
remainingSteps: [...remainingSteps],
|
|
158
|
+
context: context ?? null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate the shape of parsed JSON and return a typed, defensively-copied
|
|
163
|
+
* CoordinatoriumState. Throws StateSchemaError for anything that does not
|
|
164
|
+
* match a supported schema version — this function never "fixes" or
|
|
165
|
+
* partially accepts malformed state.
|
|
166
|
+
*
|
|
167
|
+
* Supports exactly two on-disk shapes:
|
|
168
|
+
* - `schemaVersion: 2` (current) — `tactical.checkpoints` is required.
|
|
169
|
+
* - `schemaVersion: 1` (legacy, pre-COORDINATORIUM-002) — migrated
|
|
170
|
+
* in-memory to v2 on read (see `migrateV1ToV2`). Anything else
|
|
171
|
+
* (including a *newer* version this binary doesn't know about) is
|
|
172
|
+
* rejected outright — this is the deliberate fix for the compatibility
|
|
173
|
+
* gap an independent review found in the first COORDINATORIUM-002 cut:
|
|
174
|
+
* that cut kept `schemaVersion` at 1 while adding `tactical.checkpoints`,
|
|
175
|
+
* so an *older* binary (hardcoded to `schemaVersion === 1`, with no
|
|
176
|
+
* notion of checkpoints) could read a checkpoint-bearing v1-tagged file,
|
|
177
|
+
* reconstruct state from only the fields it knew about (`tactical: {
|
|
178
|
+
* missions }`, dropping checkpoints entirely — this function has never
|
|
179
|
+
* been a passthrough for unknown fields), and silently overwrite the
|
|
180
|
+
* file on its next mutateState call, destroying checkpoint history with
|
|
181
|
+
* no error raised. Bumping to 2 converts that silent data loss into a
|
|
182
|
+
* loud, immediate `StateSchemaError` for any reader that doesn't
|
|
183
|
+
* understand v2 — the version check below runs before anything is ever
|
|
184
|
+
* read into a mutable draft, so mutateState never reaches its write step
|
|
185
|
+
* (same proven pattern as the existing corrupted-JSON guard).
|
|
186
|
+
*/
|
|
187
|
+
export function validateState(raw) {
|
|
188
|
+
if (!isPlainObject(raw)) {
|
|
189
|
+
throw new StateSchemaError('State root is not an object');
|
|
190
|
+
}
|
|
191
|
+
if (raw.schemaVersion !== STATE_SCHEMA_VERSION && raw.schemaVersion !== LEGACY_SCHEMA_VERSION_V1) {
|
|
192
|
+
throw new StateSchemaError(`Unsupported schemaVersion "${String(raw.schemaVersion)}" ` +
|
|
193
|
+
`(expected ${STATE_SCHEMA_VERSION}, or legacy ${LEGACY_SCHEMA_VERSION_V1} for migration)`);
|
|
194
|
+
}
|
|
195
|
+
if (typeof raw.revision !== 'number' || !Number.isInteger(raw.revision) || raw.revision < 0) {
|
|
196
|
+
throw new StateSchemaError(`Invalid revision "${String(raw.revision)}"`);
|
|
197
|
+
}
|
|
198
|
+
if (!isPlainObject(raw.strategic) || !isPlainObject(raw.strategic.systems)) {
|
|
199
|
+
throw new StateSchemaError('Invalid strategic.systems');
|
|
200
|
+
}
|
|
201
|
+
if (!isPlainObject(raw.tactical) || !isPlainObject(raw.tactical.missions)) {
|
|
202
|
+
throw new StateSchemaError('Invalid tactical.missions');
|
|
203
|
+
}
|
|
204
|
+
const missions = {};
|
|
205
|
+
for (const [id, m] of Object.entries(raw.tactical.missions)) {
|
|
206
|
+
missions[id] = validateMission(id, m);
|
|
207
|
+
}
|
|
208
|
+
const rawCheckpoints = raw.tactical.checkpoints;
|
|
209
|
+
const checkpoints = {};
|
|
210
|
+
if (raw.schemaVersion === LEGACY_SCHEMA_VERSION_V1) {
|
|
211
|
+
// v1→v2 migration path: a genuine pre-COORDINATORIUM-002 file has no
|
|
212
|
+
// `checkpoints` key at all — default to {}. Tolerate (and validate) one
|
|
213
|
+
// being present anyway, defensively, in case a file was ever written by
|
|
214
|
+
// the since-corrected transitional code that kept schemaVersion at 1
|
|
215
|
+
// while already writing checkpoints — nothing is lost either way.
|
|
216
|
+
if (rawCheckpoints !== undefined && !isPlainObject(rawCheckpoints)) {
|
|
217
|
+
throw new StateSchemaError('Invalid tactical.checkpoints');
|
|
218
|
+
}
|
|
219
|
+
if (rawCheckpoints !== undefined) {
|
|
220
|
+
for (const [id, c] of Object.entries(rawCheckpoints)) {
|
|
221
|
+
checkpoints[id] = validateCheckpoint(id, c);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// schemaVersion === STATE_SCHEMA_VERSION (2): every v2 writer always
|
|
227
|
+
// serializes tactical.checkpoints (even as {}), so its absence here is
|
|
228
|
+
// itself a sign of a malformed/hand-edited file, not a legacy case —
|
|
229
|
+
// required, not defaulted.
|
|
230
|
+
if (!isPlainObject(rawCheckpoints)) {
|
|
231
|
+
throw new StateSchemaError('Invalid tactical.checkpoints');
|
|
232
|
+
}
|
|
233
|
+
for (const [id, c] of Object.entries(rawCheckpoints)) {
|
|
234
|
+
checkpoints[id] = validateCheckpoint(id, c);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Migration is applied purely in-memory here — revision and every
|
|
238
|
+
// mission/checkpoint field are carried over exactly, only schemaVersion
|
|
239
|
+
// is relabeled. No write happens during a read (same "reads are never a
|
|
240
|
+
// side effect" rule as the missing-file case in readState below); the
|
|
241
|
+
// migrated v2 shape is only ever persisted to disk the next time
|
|
242
|
+
// mutateState's existing atomic read-modify-write cycle runs — no
|
|
243
|
+
// separate migration-write path, no new on-disk mechanism.
|
|
244
|
+
return {
|
|
245
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
246
|
+
revision: raw.revision,
|
|
247
|
+
strategic: { systems: { ...raw.strategic.systems } },
|
|
248
|
+
tactical: { missions, checkpoints },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Read current state without taking a lock. If the file does not exist,
|
|
253
|
+
* returns a fresh initial state WITHOUT persisting it — read paths never
|
|
254
|
+
* have the side effect of creating state.json. Corrupted JSON or an
|
|
255
|
+
* unsupported schema throws rather than being silently replaced.
|
|
256
|
+
*/
|
|
257
|
+
export async function readState(target) {
|
|
258
|
+
const { boundary, stateDir } = resolveReadTarget(target, 'readState');
|
|
259
|
+
const filePath = stateFilePath(stateDir);
|
|
260
|
+
// A trusted directory that does not exist holds no state (it is the boundary,
|
|
261
|
+
// so it cannot be walked to). In `project` scope a missing directory is
|
|
262
|
+
// reported by the reader itself as FILE_MISSING. Only ENOENT on the path itself
|
|
263
|
+
// counts as "does not exist": `existsSync` would also say so for a dangling
|
|
264
|
+
// symlink or a loop, and an unusable boundary is not an empty state — those fall
|
|
265
|
+
// through to the reader, which refuses them (READ_BOUNDARY_INVALID) (L-2).
|
|
266
|
+
if (target.scope === 'trusted-directory') {
|
|
267
|
+
try {
|
|
268
|
+
lstatSync(stateDir);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
if (err.code === 'ENOENT')
|
|
272
|
+
return createInitialState();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Unified safe read (SEC-001B.1-R): explicit boundary, no symlink or hardlink,
|
|
276
|
+
// non-blocking (a FIFO at this path cannot hang us), capped at MAX_STATE_BYTES.
|
|
277
|
+
const read = readBoundedFileStable(filePath, `"${filePath}"`, { boundary, maxBytes: MAX_STATE_BYTES, allowEmpty: true }, true);
|
|
278
|
+
if (!read.ok) {
|
|
279
|
+
if (read.code === 'FILE_MISSING')
|
|
280
|
+
return createInitialState();
|
|
281
|
+
throw new StateFileError(read.code, read.detail);
|
|
282
|
+
}
|
|
283
|
+
const raw = 'content' in read ? read.content : '';
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(raw);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
throw new StateCorruptedError(`"${filePath}" is not valid JSON: ${msg}`);
|
|
291
|
+
}
|
|
292
|
+
return validateState(parsed);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The read that precedes a write. A symlink or hardlink at the destination is
|
|
296
|
+
* reported with the write-side typed error and `NOT_PUBLISHED`, exactly as before
|
|
297
|
+
* the unified read (SEC-001B.2 compat). Every other read failure — a FIFO or
|
|
298
|
+
* special file included (SEC-001B.1) — stays the StateFileError of the read.
|
|
299
|
+
*/
|
|
300
|
+
async function readStateForWrite(target, stateDir) {
|
|
301
|
+
try {
|
|
302
|
+
return await readState(target);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (err instanceof StateFileError) {
|
|
306
|
+
const code = err.fileCode === 'FILE_SYMLINK'
|
|
307
|
+
? 'WRITE_TARGET_SYMLINK'
|
|
308
|
+
: err.fileCode === 'FILE_HARDLINKED'
|
|
309
|
+
? 'WRITE_TARGET_HARDLINKED'
|
|
310
|
+
: null;
|
|
311
|
+
if (code !== null) {
|
|
312
|
+
throw new SafeWriteError(`${err.message} (refusing to replace it)`, {
|
|
313
|
+
code,
|
|
314
|
+
phase: 'validate',
|
|
315
|
+
publication: 'NOT_PUBLISHED',
|
|
316
|
+
path: stateFilePath(stateDir),
|
|
317
|
+
cause: err,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Persist `state` through the safe-write primitive (SEC-001B.2, ADR-012):
|
|
326
|
+
* temp file (`O_EXCL|O_NOFOLLOW`, `0600`) -> fsync -> `rename`, then fsync of
|
|
327
|
+
* the directory. A new `state.json` is `0600`; an existing one keeps its mode.
|
|
328
|
+
* A symlink, FIFO/special file or hardlinked file at the destination is
|
|
329
|
+
* refused (never replaced). Throws SafeWriteError with publication
|
|
330
|
+
* NOT_PUBLISHED or UNKNOWN; once published nothing throws — later findings are
|
|
331
|
+
* returned as warnings.
|
|
332
|
+
*/
|
|
333
|
+
function atomicWriteState(parent, filePath, state) {
|
|
334
|
+
const serialized = `${JSON.stringify(state, null, 2)}\n`;
|
|
335
|
+
// Refuse BEFORE creating the temp file: an oversized state.json would be
|
|
336
|
+
// unreadable by readState, locking the project out of its own state. The
|
|
337
|
+
// existing state.json is left untouched; nothing is compacted or dropped.
|
|
338
|
+
const bytes = Buffer.byteLength(serialized, 'utf8');
|
|
339
|
+
if (bytes > MAX_STATE_BYTES) {
|
|
340
|
+
throw new StateFileError('FILE_TOO_LARGE', `refusing to write "${filePath}": ${bytes} bytes exceeds the ${MAX_STATE_BYTES}-byte limit (see STATE-001); state was not modified.`);
|
|
341
|
+
}
|
|
342
|
+
return replaceFile(parent, STATE_FILE_NAME, serialized, {
|
|
343
|
+
mode: 0o600,
|
|
344
|
+
preserveMode: true,
|
|
345
|
+
durability: 'full',
|
|
346
|
+
maxBytes: MAX_STATE_BYTES,
|
|
347
|
+
}).warnings;
|
|
348
|
+
}
|
|
349
|
+
/** The write is already published; a throwing callback must not turn it into a failure. */
|
|
350
|
+
function reportWarnings(warnings, onWarning) {
|
|
351
|
+
if (!onWarning)
|
|
352
|
+
return;
|
|
353
|
+
for (const w of warnings) {
|
|
354
|
+
try {
|
|
355
|
+
onWarning(w);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
/* ignored on purpose */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Read-modify-write under an exclusive cross-process lock. `mutator`
|
|
364
|
+
* receives a deep-cloned draft of the current state and an `applyResult`
|
|
365
|
+
* callback to hand back an arbitrary value (e.g. the mission it just
|
|
366
|
+
* created) alongside the write. The revision is incremented exactly once
|
|
367
|
+
* per successful call, after the mutator returns.
|
|
368
|
+
*/
|
|
369
|
+
export async function mutateState(target, mutator, lockOptions) {
|
|
370
|
+
const { boundary, stateDir, onWarning } = resolveWriteTarget(target, 'mutateState');
|
|
371
|
+
const warnings = [];
|
|
372
|
+
// The instance's state directory may not exist yet (e.g. an existing
|
|
373
|
+
// project's first mission before any state.json has ever been written).
|
|
374
|
+
// Creating it here — component by component, refusing symlinks below the
|
|
375
|
+
// boundary — is the "safe, explicit initialization path" for that case; it
|
|
376
|
+
// never touches a state.json that already exists.
|
|
377
|
+
const parent = openParent(boundary, stateDir, { create: true });
|
|
378
|
+
const release = await acquireLock(lockFilePath(stateDir), lockOptions, {}, parent);
|
|
379
|
+
let draft;
|
|
380
|
+
let result;
|
|
381
|
+
try {
|
|
382
|
+
const current = await readStateForWrite(target, stateDir);
|
|
383
|
+
draft = structuredClone(current);
|
|
384
|
+
result = mutator(draft);
|
|
385
|
+
draft.revision = current.revision + 1;
|
|
386
|
+
warnings.push(...atomicWriteState(parent, stateFilePath(stateDir), draft));
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
release();
|
|
390
|
+
}
|
|
391
|
+
// The write is published and the lock is released: caller code runs outside
|
|
392
|
+
// the critical section, so a slow or re-entrant callback cannot hold up (or
|
|
393
|
+
// deadlock against) other writers.
|
|
394
|
+
reportWarnings(warnings, onWarning);
|
|
395
|
+
return { state: draft, result, warnings };
|
|
396
|
+
}
|