@hassangameryt/freeflow 0.4.0 → 0.6.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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -1
- package/.codex-plugin/plugin.json +2 -1
- package/README.md +150 -155
- package/capabilities/cognitive-routing/SKILL.md +229 -0
- package/capabilities/context-virtualization/SKILL.md +103 -0
- package/capabilities/conversation-history/SKILL.md +53 -0
- package/command-surface.json +17 -13
- package/hooks/freeflow-runtime-context.mjs +393 -159
- package/hooks/hooks.json +26 -1
- package/hooks/tests/check-runtime-context-hook.sh +223 -54
- package/package.json +25 -16
- package/pi-extension/dist/cognitive-routing/commands.js +96 -0
- package/pi-extension/dist/cognitive-routing/config.js +236 -0
- package/pi-extension/dist/cognitive-routing/controller.js +862 -0
- package/pi-extension/dist/cognitive-routing/history.js +439 -0
- package/pi-extension/dist/cognitive-routing/host.js +126 -0
- package/pi-extension/dist/cognitive-routing/runtime.js +43 -0
- package/pi-extension/dist/cognitive-routing/tool.js +211 -0
- package/pi-extension/dist/cognitive-routing/types.js +6 -0
- package/pi-extension/dist/context-virtualization/commands.js +89 -0
- package/pi-extension/dist/context-virtualization/projector.js +42 -0
- package/pi-extension/dist/context-virtualization/runtime.js +356 -0
- package/pi-extension/dist/context-virtualization/state.js +57 -0
- package/pi-extension/dist/context-virtualization/types.js +10 -0
- package/pi-extension/dist/conversation-history/passages.js +66 -0
- package/pi-extension/dist/conversation-history/ranking.js +105 -0
- package/pi-extension/dist/conversation-history/runtime.js +226 -0
- package/pi-extension/dist/conversation-history/search.js +56 -0
- package/pi-extension/dist/conversation-history/types.js +1 -0
- package/pi-extension/dist/freeflow-context/resolver.js +120 -0
- package/pi-extension/dist/freeflow-context/runtime.js +80 -0
- package/pi-extension/dist/freeflow-context/source-projector.js +95 -0
- package/pi-extension/dist/freeflow-context/tool.js +308 -0
- package/pi-extension/dist/freeflow-context/types.js +12 -0
- package/pi-extension/dist/freeflow-context/visibility.js +28 -0
- package/pi-extension/dist/index.js +516 -98
- package/pi-extension/dist/runtime/runtime-context.js +318 -271
- package/pi-extension/dist/runtime/runtime-identity.js +5 -0
- package/pi-extension/dist/settings/settings-tui.js +78 -0
- package/pi-extension/dist/settings/settings-ui.js +593 -563
- package/pi-extension/dist/ui/text-component.js +57 -0
- package/pi-extension/src/cognitive-routing/commands.ts +119 -0
- package/pi-extension/src/cognitive-routing/config.ts +307 -0
- package/pi-extension/src/cognitive-routing/controller.ts +1118 -0
- package/pi-extension/src/cognitive-routing/history.ts +638 -0
- package/pi-extension/src/cognitive-routing/host.ts +172 -0
- package/pi-extension/src/cognitive-routing/runtime.ts +57 -0
- package/pi-extension/src/cognitive-routing/tool.ts +272 -0
- package/pi-extension/src/cognitive-routing/types.ts +89 -0
- package/pi-extension/src/context-virtualization/commands.ts +111 -0
- package/pi-extension/src/context-virtualization/projector.ts +62 -0
- package/pi-extension/src/context-virtualization/runtime.ts +406 -0
- package/pi-extension/src/context-virtualization/state.ts +79 -0
- package/pi-extension/src/context-virtualization/types.ts +92 -0
- package/pi-extension/src/conversation-history/passages.ts +81 -0
- package/pi-extension/src/conversation-history/ranking.ts +155 -0
- package/pi-extension/src/conversation-history/runtime.ts +250 -0
- package/pi-extension/src/conversation-history/search.ts +79 -0
- package/pi-extension/src/conversation-history/types.ts +31 -0
- package/pi-extension/src/freeflow-context/resolver.ts +139 -0
- package/pi-extension/src/freeflow-context/runtime.ts +100 -0
- package/pi-extension/src/freeflow-context/source-projector.ts +106 -0
- package/pi-extension/src/freeflow-context/tool.ts +360 -0
- package/pi-extension/src/freeflow-context/types.ts +70 -0
- package/pi-extension/src/freeflow-context/visibility.ts +53 -0
- package/pi-extension/src/index.ts +553 -101
- package/pi-extension/src/runtime/runtime-context.ts +339 -281
- package/pi-extension/src/runtime/runtime-identity.ts +8 -0
- package/pi-extension/src/settings/settings-tui.ts +93 -0
- package/pi-extension/src/settings/settings-ui.ts +700 -613
- package/pi-extension/src/ui/text-component.ts +66 -0
- package/pi-extension/tests/cognitive-routing/commands.test.js +134 -0
- package/pi-extension/tests/cognitive-routing/controller.test.js +1099 -0
- package/pi-extension/tests/cognitive-routing/extension-lifecycle.test.js +1071 -0
- package/pi-extension/tests/cognitive-routing/history.test.js +274 -0
- package/pi-extension/tests/cognitive-routing/host-fixture.js +4 -0
- package/pi-extension/tests/cognitive-routing/host-identity.test.js +63 -0
- package/pi-extension/tests/cognitive-routing/profile-resolution.test.js +236 -0
- package/pi-extension/tests/cognitive-routing/tool.test.js +230 -0
- package/pi-extension/tests/context-virtualization/extension.test.js +749 -0
- package/pi-extension/tests/context-virtualization/projector.test.js +110 -0
- package/pi-extension/tests/context-virtualization/runtime.test.js +216 -0
- package/pi-extension/tests/conversation-history/runtime.test.js +98 -0
- package/pi-extension/tests/conversation-history/search.test.js +97 -0
- package/pi-extension/tests/integration/pi-extension.test.js +928 -2047
- package/pi-extension/tests/integration/prompt-architecture.test.js +305 -0
- package/runtime/prompts/cognitive-routing.md +9 -0
- package/runtime/prompts/context-virtualization.md +3 -0
- package/runtime/prompts/conversation-history.md +3 -0
- package/runtime/prompts/core.md +13 -0
- package/runtime/prompts/interaction-contract.md +7 -0
- package/runtime/prompts/skills.md +65 -0
- package/skills/action-selection/SKILL.md +91 -0
- package/skills/bypass/SKILL.md +3 -2
- package/skills/commit-work/SKILL.md +1 -1
- package/skills/commit-work/references/staging-decisions.md +1 -1
- package/skills/decision-gate/SKILL.md +1 -1
- package/skills/design-for-depth/SKILL.md +14 -33
- package/skills/design-for-depth/references/design-pressure-signals.md +1 -1
- package/skills/design-for-depth/references/interface-design-loop.md +1 -1
- package/skills/design-for-depth/references/software-design-philosophy.md +1 -1
- package/skills/diagnose-failure/SKILL.md +20 -14
- package/skills/diagnose-failure/references/diagnostic-loop-catalog.md +1 -3
- package/skills/diagnose-failure/references/{flaky-and-performance.md → flaky-and-performance-diagnosis.md} +1 -1
- package/skills/discuss/SKILL.md +34 -22
- package/skills/discuss/references/discussion-continuity.md +71 -0
- package/skills/evaluate-skill/SKILL.md +5 -5
- package/skills/evaluate-skill/references/{definition-schema.md → evaluation-definition-schema.md} +42 -2
- package/skills/evaluate-skill/references/execution-and-evidence.md +8 -3
- package/skills/evaluate-skill/scripts/lib/definitions.mjs +114 -3
- package/skills/evaluate-skill/scripts/lib/grade-interaction.mjs +156 -1
- package/skills/evaluate-skill/scripts/lib/grade.mjs +9 -1
- package/skills/evaluate-skill/scripts/lib/pi.mjs +147 -32
- package/skills/evaluate-skill/scripts/lib/rpc.mjs +4 -4
- package/skills/evaluate-skill/scripts/lib/runner.mjs +36 -14
- package/skills/evaluate-skill/scripts/lib/sandbox.mjs +92 -0
- package/skills/evaluate-skill/scripts/pi-guard.mjs +1 -1
- package/skills/evaluate-skill/scripts/pi-observer.mjs +66 -0
- package/skills/evaluate-skill/scripts/skill-eval.mjs +6 -5
- package/skills/execute-work/SKILL.md +18 -15
- package/skills/execute-work/references/code-practices.md +1 -1
- package/skills/execute-work/references/execute-work-edges.md +42 -0
- package/skills/finish-branch/SKILL.md +2 -2
- package/skills/finish-branch/references/integration-options.md +1 -1
- package/skills/handoff/SKILL.md +3 -3
- package/skills/launch-work/SKILL.md +1 -1
- package/skills/launch-work/references/launch-readiness.md +2 -2
- package/skills/migration-work/SKILL.md +2 -2
- package/skills/migration-work/references/migration-lifecycle.md +1 -1
- package/skills/mode-contract/SKILL.md +32 -10
- package/skills/release-work/SKILL.md +1 -1
- package/skills/release-work/references/release-evidence.md +1 -1
- package/skills/review-artifact/SKILL.md +28 -26
- package/skills/review-work/SKILL.md +28 -26
- package/skills/review-work/references/{reviewer-prompt.md → independent-work-reviewer-contract.md} +1 -1
- package/skills/review-work/references/security-risk-lens.md +1 -1
- package/skills/setup-freeflow/SKILL.md +6 -10
- package/skills/setup-freeflow/references/activation-contract.md +5 -10
- package/skills/setup-freeflow/references/host-setup.md +3 -7
- package/skills/simplify-code/SKILL.md +1 -1
- package/skills/simplify-code/references/simplification-patterns.md +1 -1
- package/skills/tdd/SKILL.md +18 -38
- package/skills/tdd/references/test-design.md +2 -2
- package/skills/track-work/SKILL.md +362 -58
- package/skills/track-work/scripts/lib/codec.mjs +570 -0
- package/skills/track-work/scripts/lib/commands.mjs +568 -0
- package/skills/track-work/scripts/lib/contract.mjs +303 -0
- package/skills/track-work/scripts/lib/model.mjs +506 -0
- package/skills/track-work/scripts/lib/operations.mjs +965 -0
- package/skills/track-work/scripts/lib/record-store.mjs +264 -0
- package/skills/track-work/scripts/lib/result.mjs +37 -0
- package/skills/track-work/scripts/lib/rewrites.mjs +740 -0
- package/skills/track-work/scripts/lib/schema.mjs +277 -0
- package/skills/track-work/scripts/lib/source-inventory.mjs +162 -0
- package/skills/track-work/scripts/lib/views.mjs +443 -0
- package/skills/track-work/scripts/lib/workspace.mjs +134 -0
- package/skills/track-work/scripts/working-record.mjs +122 -0
- package/skills/verify-work/SKILL.md +14 -2
- package/skills/verify-work/references/browser-runtime-evidence.md +1 -1
- package/skills/verify-work/references/integration-evidence.md +1 -1
- package/skills/verify-work/references/performance-evidence.md +1 -1
- package/skills/workflow/SKILL.md +105 -53
- package/skills/workflow/references/domain-skill-composition.md +22 -42
- package/skills/write-plan/SKILL.md +1 -1
- package/skills/write-skill/SKILL.md +3 -2
- package/skills/write-skill/references/progressive-disclosure.md +17 -3
- package/pi-extension/dist/output-router/host-producer-identification.js +0 -232
- package/pi-extension/dist/output-router/native-safety-net.js +0 -216
- package/pi-extension/dist/output-router/observed-tool-routing.js +0 -122
- package/pi-extension/dist/output-router/renderers.js +0 -1023
- package/pi-extension/dist/output-router/router-tools.js +0 -554
- package/pi-extension/dist/output-router/schemas.js +0 -383
- package/pi-extension/dist/output-router/status.js +0 -744
- package/pi-extension/dist/output-router/utils.js +0 -502
- package/pi-extension/src/output-router/host-producer-identification.ts +0 -256
- package/pi-extension/src/output-router/native-safety-net.ts +0 -248
- package/pi-extension/src/output-router/observed-tool-routing.ts +0 -134
- package/pi-extension/src/output-router/renderers.ts +0 -1108
- package/pi-extension/src/output-router/router-tools.ts +0 -579
- package/pi-extension/src/output-router/schemas.ts +0 -387
- package/pi-extension/src/output-router/status.ts +0 -791
- package/pi-extension/src/output-router/utils.ts +0 -554
- package/pi-extension/tests/output-router/observed-producers.test.js +0 -132
- package/pi-extension/tests/output-router/search.test.js +0 -722
- package/router/dist/benchmarks/benchmark-harness.d.ts +0 -35
- package/router/dist/benchmarks/benchmark-harness.js +0 -92
- package/router/dist/benchmarks/benchmarks.d.ts +0 -109
- package/router/dist/benchmarks/benchmarks.js +0 -979
- package/router/dist/benchmarks/codex-qa-benchmarks.d.ts +0 -95
- package/router/dist/benchmarks/codex-qa-benchmarks.js +0 -500
- package/router/dist/benchmarks/command-benchmarks.d.ts +0 -143
- package/router/dist/benchmarks/command-benchmarks.js +0 -698
- package/router/dist/benchmarks/context-mode-normalized-benchmarks.d.ts +0 -82
- package/router/dist/benchmarks/context-mode-normalized-benchmarks.js +0 -773
- package/router/dist/benchmarks/context-mode-real-deep-benchmark.d.ts +0 -132
- package/router/dist/benchmarks/context-mode-real-deep-benchmark.js +0 -1380
- package/router/dist/benchmarks/context-saving-baseline.d.ts +0 -60
- package/router/dist/benchmarks/context-saving-baseline.js +0 -470
- package/router/dist/benchmarks/index-benchmarks.d.ts +0 -103
- package/router/dist/benchmarks/index-benchmarks.js +0 -862
- package/router/dist/benchmarks/storage-policy-benchmarks.d.ts +0 -101
- package/router/dist/benchmarks/storage-policy-benchmarks.js +0 -618
- package/router/dist/benchmarks/vault-index-benchmarks.d.ts +0 -56
- package/router/dist/benchmarks/vault-index-benchmarks.js +0 -322
- package/router/dist/config/config.d.ts +0 -103
- package/router/dist/config/config.js +0 -632
- package/router/dist/config/router-contract.d.ts +0 -10
- package/router/dist/config/router-contract.js +0 -54
- package/router/dist/config/schema.d.ts +0 -27
- package/router/dist/config/schema.js +0 -648
- package/router/dist/config/types.d.ts +0 -573
- package/router/dist/config/types.js +0 -41
- package/router/dist/evidence/bounded-evidence.d.ts +0 -40
- package/router/dist/evidence/bounded-evidence.js +0 -323
- package/router/dist/evidence/evidence-range-selector.d.ts +0 -24
- package/router/dist/evidence/evidence-range-selector.js +0 -232
- package/router/dist/evidence/evidence-search.d.ts +0 -27
- package/router/dist/evidence/evidence-search.js +0 -490
- package/router/dist/evidence/evidence.d.ts +0 -38
- package/router/dist/evidence/evidence.js +0 -200
- package/router/dist/evidence/failure-contracts.d.ts +0 -37
- package/router/dist/evidence/failure-contracts.js +0 -166
- package/router/dist/evidence/line-ranges.d.ts +0 -24
- package/router/dist/evidence/line-ranges.js +0 -22
- package/router/dist/experiments/local-index.d.ts +0 -64
- package/router/dist/experiments/local-index.js +0 -581
- package/router/dist/index.d.ts +0 -30
- package/router/dist/index.js +0 -32
- package/router/dist/local/local-traversal.d.ts +0 -18
- package/router/dist/local/local-traversal.js +0 -334
- package/router/dist/processing/engine.d.ts +0 -132
- package/router/dist/processing/engine.js +0 -533
- package/router/dist/processing/reducers.d.ts +0 -280
- package/router/dist/processing/reducers.js +0 -1673
- package/router/dist/processing/renderers.d.ts +0 -31
- package/router/dist/processing/renderers.js +0 -195
- package/router/dist/processing/scripts.d.ts +0 -91
- package/router/dist/processing/scripts.js +0 -386
- package/router/dist/repo/repo-traversal.d.ts +0 -18
- package/router/dist/repo/repo-traversal.js +0 -245
- package/router/dist/routing/observed-reducers.d.ts +0 -41
- package/router/dist/routing/observed-reducers.js +0 -661
- package/router/dist/routing/observed-routing.d.ts +0 -26
- package/router/dist/routing/observed-routing.js +0 -378
- package/router/dist/routing/parsers.d.ts +0 -16
- package/router/dist/routing/parsers.js +0 -521
- package/router/dist/routing/run-filters.d.ts +0 -60
- package/router/dist/routing/run-filters.js +0 -238
- package/router/dist/routing/run-reducers.d.ts +0 -49
- package/router/dist/routing/run-reducers.js +0 -122
- package/router/dist/sandbox/adapter-roots.d.ts +0 -46
- package/router/dist/sandbox/adapter-roots.js +0 -66
- package/router/dist/sandbox/eryx-python-adapter.d.ts +0 -13
- package/router/dist/sandbox/eryx-python-adapter.js +0 -868
- package/router/dist/sandbox/jq-wasm-adapter.d.ts +0 -9
- package/router/dist/sandbox/jq-wasm-adapter.js +0 -429
- package/router/dist/sandbox/quickjs-wasi-adapter.d.ts +0 -13
- package/router/dist/sandbox/quickjs-wasi-adapter.js +0 -384
- package/router/dist/sandbox/script-sandbox.d.ts +0 -134
- package/router/dist/sandbox/script-sandbox.js +0 -337
- package/router/dist/setup/script-transform-adapters.d.ts +0 -32
- package/router/dist/setup/script-transform-adapters.js +0 -278
- package/router/dist/tools/batch.d.ts +0 -33
- package/router/dist/tools/batch.js +0 -586
- package/router/dist/tools/run.d.ts +0 -62
- package/router/dist/tools/run.js +0 -1304
- package/router/dist/tools/search.d.ts +0 -66
- package/router/dist/tools/search.js +0 -1364
- package/router/dist/transform/engine.d.ts +0 -168
- package/router/dist/transform/engine.js +0 -2072
- package/router/dist/vault/vault-index.d.ts +0 -125
- package/router/dist/vault/vault-index.js +0 -416
- package/router/dist/vault/vault.d.ts +0 -138
- package/router/dist/vault/vault.js +0 -630
- package/runtime/interaction-contract.md +0 -16
- package/skills/discuss/references/checkpoints.md +0 -66
- package/skills/execute-work/references/execution-loop.md +0 -77
- package/skills/output-router/SKILL.md +0 -206
- package/skills/output-router/references/safety-policy.md +0 -40
- package/skills/setup-freeflow/references/output-router-setup.md +0 -110
- package/skills/track-work/references/working-record-schema.md +0 -378
- package/skills/workflow/references/workflow-loop.md +0 -92
- /package/skills/handoff/references/{templates.md → handoff-templates.md} +0 -0
- /package/skills/review-artifact/references/{reviewer-prompt.md → independent-artifact-reviewer-contract.md} +0 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HISTORICAL_SLICE_STATES,
|
|
3
|
+
TASK_TRANSITIONS,
|
|
4
|
+
SLICE_TYPES,
|
|
5
|
+
TASK_STATES,
|
|
6
|
+
assertCompleteSlice,
|
|
7
|
+
clone,
|
|
8
|
+
compactObject,
|
|
9
|
+
ensureArray,
|
|
10
|
+
ensureString,
|
|
11
|
+
fail,
|
|
12
|
+
nextId,
|
|
13
|
+
normalizeLineEndings,
|
|
14
|
+
normalizeDecision,
|
|
15
|
+
normalizeCheckpoint,
|
|
16
|
+
normalizeProposal,
|
|
17
|
+
normalizeSlice,
|
|
18
|
+
} from "./model.mjs";
|
|
19
|
+
import {
|
|
20
|
+
CHECKPOINT_RESULTS,
|
|
21
|
+
COMMAND_INPUT_KEYS,
|
|
22
|
+
CONTEXT_PATCH_FIELDS,
|
|
23
|
+
DECISION_CONTROLLED_FIELDS,
|
|
24
|
+
DECISION_ID_PATTERN,
|
|
25
|
+
DECISION_OPERATIONS,
|
|
26
|
+
EDIT_MODE_INPUT_KEYS,
|
|
27
|
+
hasDecisionContent,
|
|
28
|
+
isSingleLineTitle,
|
|
29
|
+
PRECISE_FORBIDDEN_FIELDS,
|
|
30
|
+
PRECISE_LIST_FIELDS,
|
|
31
|
+
PRECISE_STRING_FIELDS,
|
|
32
|
+
PROPOSAL_OPERATIONS,
|
|
33
|
+
NOTE_OPERATIONS,
|
|
34
|
+
SLICE_LIST_FIELDS,
|
|
35
|
+
UPDATE_INPUT_KEYS,
|
|
36
|
+
WORK_PATCH_FIELDS,
|
|
37
|
+
isSingleLineList,
|
|
38
|
+
} from "./contract.mjs";
|
|
39
|
+
|
|
40
|
+
function assertSingleLineTitle(value, path) {
|
|
41
|
+
if (!isSingleLineTitle(value)) fail("invalid-title", "Title must be a non-empty single-line string", { path });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function activeDecisionSync(data) {
|
|
45
|
+
delete data.currentContext.activeDecisions;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findById(entities, id) {
|
|
49
|
+
return entities.find((entity) => entity.id === id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertDecisionReference(value, field, path) {
|
|
53
|
+
if (value === undefined || value === "") return;
|
|
54
|
+
if (typeof value !== "string" || !DECISION_ID_PATTERN.test(value))
|
|
55
|
+
fail("invalid-decision-reference", `Decision ${field} requires a D-NNN string`, { path });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertDecisionReferences(change, path) {
|
|
59
|
+
assertDecisionReference(change.supersedes, "supersedes", `${path}.supersedes`);
|
|
60
|
+
assertDecisionReference(change.supersededBy, "supersededBy", `${path}.supersededBy`);
|
|
61
|
+
if (change.replacement) {
|
|
62
|
+
assertDecisionReference(change.replacement.supersedes, "supersedes", `${path}.replacement.supersedes`);
|
|
63
|
+
assertDecisionReference(change.replacement.supersededBy, "supersededBy", `${path}.replacement.supersededBy`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertKnownCommandInput(input, command) {
|
|
68
|
+
for (const key of Object.keys(input))
|
|
69
|
+
if (!COMMAND_INPUT_KEYS[command]?.has(key))
|
|
70
|
+
fail("unknown-input-field", `Unknown ${command} input field: ${key}`, { path: `${command}.${key}` });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertSliceListInputs(input, path) {
|
|
74
|
+
for (const field of SLICE_LIST_FIELDS) {
|
|
75
|
+
if (input[field] === undefined) continue;
|
|
76
|
+
if (!Array.isArray(input[field]) || input[field].some((item) => typeof item !== "string"))
|
|
77
|
+
fail("invalid-input-type", `Field requires an array of strings: ${path}.${field}`, {
|
|
78
|
+
path: `${path}.${field}`,
|
|
79
|
+
expected: "string[]",
|
|
80
|
+
});
|
|
81
|
+
if (!isSingleLineList(input[field]))
|
|
82
|
+
fail("invalid-list-member", `List members must be single-line strings: ${path}.${field}`, {
|
|
83
|
+
path: `${path}.${field}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertRequiredText(value, code, message, path) {
|
|
89
|
+
if (typeof value !== "string" || !value.trim()) fail(code, message, { path });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertDecisionContent(decision, path) {
|
|
93
|
+
if (!hasDecisionContent(decision))
|
|
94
|
+
fail("missing-decision-content", "Decision requires decision, rationale, or consequences", { path });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function assertProposalContent(proposal, path) {
|
|
98
|
+
assertRequiredText(
|
|
99
|
+
proposal.intendedResult,
|
|
100
|
+
"missing-proposal-content",
|
|
101
|
+
"Proposal intended result is required",
|
|
102
|
+
`${path}.intendedResult`,
|
|
103
|
+
);
|
|
104
|
+
assertRequiredText(
|
|
105
|
+
proposal.expectedEvidence,
|
|
106
|
+
"missing-proposal-content",
|
|
107
|
+
"Proposal expected evidence is required",
|
|
108
|
+
`${path}.expectedEvidence`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertNoCallerControlledDecisionFields(change, path, allowed = new Set()) {
|
|
113
|
+
for (const field of DECISION_CONTROLLED_FIELDS)
|
|
114
|
+
if (Object.hasOwn(change, field) && !allowed.has(field))
|
|
115
|
+
fail("caller-controlled-decision-field", `Decision field is controlled by its lifecycle: ${field}`, {
|
|
116
|
+
path: `${path}.${field}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function patchObject(target, patch) {
|
|
121
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
122
|
+
if (value !== undefined && key !== "currentSlice" && key !== "taskState") target[key] = clone(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyTaskState(data, input) {
|
|
127
|
+
if (input.taskState === undefined) return;
|
|
128
|
+
const next = input.taskState;
|
|
129
|
+
if (!TASK_STATES.has(next)) fail("invalid-task-state", `Invalid task state: ${next}`);
|
|
130
|
+
const current = data.taskState;
|
|
131
|
+
const authority = input.taskStateAuthority ?? input.authoritySource ?? input.userDirection;
|
|
132
|
+
if (current !== next && !authority)
|
|
133
|
+
fail(
|
|
134
|
+
"missing-authority",
|
|
135
|
+
"Explicit task-state changes require taskStateAuthority, authoritySource, or userDirection",
|
|
136
|
+
);
|
|
137
|
+
if (!TASK_TRANSITIONS.get(current)?.has(next))
|
|
138
|
+
fail("invalid-transition", `Task state transition ${current} → ${next} is not allowed`);
|
|
139
|
+
if ((current === "Completed" || current === "Abandoned") && next === "Active" && data.currentWork.currentSlice)
|
|
140
|
+
fail("invalid-transition", "A reopened terminal task cannot retain a Current Slice");
|
|
141
|
+
if ((next === "Completed" || next === "Abandoned") && data.currentWork.currentSlice)
|
|
142
|
+
fail("terminal-current-slice", "Clear the Current Slice before setting a terminal task state");
|
|
143
|
+
data.taskState = next;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertUpdateValue(value, type, path) {
|
|
147
|
+
if (type === "string" && typeof value !== "string")
|
|
148
|
+
fail("invalid-input-type", `Field requires a string: ${path}`, { path, expected: "string" });
|
|
149
|
+
if (type === "array") {
|
|
150
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
|
|
151
|
+
fail("invalid-input-type", `Field requires an array of strings: ${path}`, { path, expected: "string[]" });
|
|
152
|
+
if (!isSingleLineList(value))
|
|
153
|
+
fail("invalid-list-member", `List members must be single-line strings: ${path}`, { path });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function assertUpdatePatch(patch, fields, path) {
|
|
158
|
+
if (patch === undefined) return {};
|
|
159
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch))
|
|
160
|
+
fail("invalid-input-type", `Patch requires an object: ${path}`, { path, expected: "object" });
|
|
161
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
162
|
+
const type = fields[key];
|
|
163
|
+
if (!type) fail("unknown-input-field", `Unknown update field: ${key}`, { path: `${path}.${key}` });
|
|
164
|
+
if (type === "forbidden")
|
|
165
|
+
fail("invalid-operation", "Use start, block, resume, or close to change Current Slice ownership", {
|
|
166
|
+
path: `${path}.${key}`,
|
|
167
|
+
});
|
|
168
|
+
assertUpdateValue(value, type, `${path}.${key}`);
|
|
169
|
+
}
|
|
170
|
+
return patch;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function applyContextPatch(data, input) {
|
|
174
|
+
const contextPatch = { ...assertUpdatePatch(input.currentContext, CONTEXT_PATCH_FIELDS, "input.currentContext") };
|
|
175
|
+
const aliases = {
|
|
176
|
+
goal: input.goal,
|
|
177
|
+
whatDefinesTask: input.whatDefinesTask,
|
|
178
|
+
settled: input.settled,
|
|
179
|
+
tentative: input.tentative,
|
|
180
|
+
open: input.open,
|
|
181
|
+
currentDirection: input.currentDirection,
|
|
182
|
+
boundaries: input.boundaries,
|
|
183
|
+
};
|
|
184
|
+
for (const [key, value] of Object.entries(aliases)) {
|
|
185
|
+
if (value !== undefined) {
|
|
186
|
+
assertUpdateValue(value, CONTEXT_PATCH_FIELDS[key], `input.${key}`);
|
|
187
|
+
contextPatch[key] = value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
patchObject(data.currentContext, contextPatch);
|
|
191
|
+
|
|
192
|
+
const currentWorkPatch = assertUpdatePatch(input.currentWork, WORK_PATCH_FIELDS, "input.currentWork");
|
|
193
|
+
patchObject(data.currentWork, currentWorkPatch);
|
|
194
|
+
|
|
195
|
+
const workAliases = {
|
|
196
|
+
route: input.currentRoute ?? input.route,
|
|
197
|
+
nextAction: input.nextAction ?? input.nextUsefulAction,
|
|
198
|
+
blockers: input.blockers,
|
|
199
|
+
upcomingCheckpoints: input.upcomingCheckpoints,
|
|
200
|
+
};
|
|
201
|
+
for (const [key, value] of Object.entries(workAliases)) {
|
|
202
|
+
if (value !== undefined) {
|
|
203
|
+
assertUpdateValue(value, WORK_PATCH_FIELDS[key], `input.${key}`);
|
|
204
|
+
data.currentWork[key] = clone(value);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function applyDecisionChange(data, input) {
|
|
210
|
+
if (Array.isArray(input.decisions)) {
|
|
211
|
+
for (const decision of input.decisions) applyDecisionChange(data, { decision });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const change = input.decision ?? (input.decisions && !Array.isArray(input.decisions) ? input.decisions : null);
|
|
215
|
+
if (!change) return;
|
|
216
|
+
const operation = change.operation ?? change.op ?? "add";
|
|
217
|
+
if (!DECISION_OPERATIONS.has(operation))
|
|
218
|
+
fail("unsupported-decision-operation", `Unsupported decision operation: ${operation}`, {
|
|
219
|
+
path: "decision.operation",
|
|
220
|
+
});
|
|
221
|
+
assertDecisionReferences(change, "decision");
|
|
222
|
+
assertNoCallerControlledDecisionFields(
|
|
223
|
+
change,
|
|
224
|
+
"decision",
|
|
225
|
+
operation === "supersede" ? new Set(["supersededBy"]) : undefined,
|
|
226
|
+
);
|
|
227
|
+
if (operation === "add") {
|
|
228
|
+
if (change.id !== undefined) fail("caller-supplied-id", "The script assigns decision IDs");
|
|
229
|
+
assertSingleLineTitle(change.title ?? change.name ?? "Untitled decision", "decision.title");
|
|
230
|
+
assertDecisionContent(change, "decision");
|
|
231
|
+
const decision = normalizeDecision(change, nextId(data, "D"));
|
|
232
|
+
if (findById(data.history.decisions, decision.id))
|
|
233
|
+
fail("duplicate-id", `Decision ID already exists: ${decision.id}`);
|
|
234
|
+
data.history.decisions.push(decision);
|
|
235
|
+
} else {
|
|
236
|
+
const existing = findById(data.history.decisions, change.id);
|
|
237
|
+
if (!existing) fail("missing-entity", `Decision does not exist: ${change.id}`);
|
|
238
|
+
if (operation === "remove" || operation === "retire") {
|
|
239
|
+
existing.state = "Retired";
|
|
240
|
+
} else if (operation === "supersede") {
|
|
241
|
+
let replacementId = change.supersededBy;
|
|
242
|
+
let replacement = replacementId ? findById(data.history.decisions, replacementId) : null;
|
|
243
|
+
if (change.replacement) {
|
|
244
|
+
if (change.replacement.id !== undefined) fail("caller-supplied-id", "The script assigns decision IDs");
|
|
245
|
+
assertNoCallerControlledDecisionFields(change.replacement, "decision.replacement");
|
|
246
|
+
assertSingleLineTitle(
|
|
247
|
+
change.replacement.title ?? change.replacement.name ?? "Untitled decision",
|
|
248
|
+
"decision.replacement.title",
|
|
249
|
+
);
|
|
250
|
+
assertDecisionContent(change.replacement, "decision.replacement");
|
|
251
|
+
replacement = normalizeDecision(change.replacement, nextId(data, "D"));
|
|
252
|
+
replacementId = replacement.id;
|
|
253
|
+
data.history.decisions.push(replacement);
|
|
254
|
+
}
|
|
255
|
+
if (!replacementId) fail("missing-superseded-by", "Supersede requires supersededBy or replacement");
|
|
256
|
+
if (replacementId === existing.id) fail("invalid-supersession", "A decision cannot supersede itself");
|
|
257
|
+
if (!replacement) fail("missing-entity", `Replacement decision does not exist: ${replacementId}`);
|
|
258
|
+
existing.state = "Superseded";
|
|
259
|
+
existing.supersededBy = replacementId;
|
|
260
|
+
replacement.supersedes = existing.id;
|
|
261
|
+
} else {
|
|
262
|
+
const updated = normalizeDecision({ ...existing, ...change }, existing.id);
|
|
263
|
+
assertDecisionContent(updated, "decision");
|
|
264
|
+
Object.assign(existing, updated);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
activeDecisionSync(data);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyProposalChange(data, input) {
|
|
271
|
+
const change = input.proposal ?? (input.proposals && !Array.isArray(input.proposals) ? input.proposals : null);
|
|
272
|
+
if (!change && !Array.isArray(input.proposals)) return;
|
|
273
|
+
if (Array.isArray(input.proposals)) {
|
|
274
|
+
data.proposals = input.proposals.map(normalizeProposal);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const operation = change.operation ?? change.op ?? "add";
|
|
278
|
+
if (!PROPOSAL_OPERATIONS.has(operation))
|
|
279
|
+
fail("unsupported-proposal-operation", `Unsupported proposal operation: ${operation}`, {
|
|
280
|
+
path: "proposal.operation",
|
|
281
|
+
});
|
|
282
|
+
assertSliceListInputs(change, "proposal");
|
|
283
|
+
const title = change.title ?? change.name;
|
|
284
|
+
const index = data.proposals.findIndex((proposal) => proposal.title === title);
|
|
285
|
+
if (operation === "add") {
|
|
286
|
+
if (index >= 0) fail("duplicate-proposal", `Proposal already exists: ${title}`);
|
|
287
|
+
assertSingleLineTitle(title, "proposal.title");
|
|
288
|
+
const proposal = normalizeProposal(change);
|
|
289
|
+
if (!proposal.title) fail("missing-proposal-title", "Proposal title is required");
|
|
290
|
+
assertProposalContent(proposal, "proposal");
|
|
291
|
+
data.proposals.push(proposal);
|
|
292
|
+
} else if (index < 0) fail("missing-proposal", `Proposal does not exist: ${title}`);
|
|
293
|
+
else if (operation === "remove") data.proposals.splice(index, 1);
|
|
294
|
+
else data.proposals[index] = normalizeProposal({ ...data.proposals[index], ...change });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function applyNoteChange(data, input) {
|
|
298
|
+
const change = input.note ?? (input.notes && !Array.isArray(input.notes) ? input.notes : null);
|
|
299
|
+
if (!change && !Array.isArray(input.notes)) return;
|
|
300
|
+
if (Array.isArray(input.notes)) {
|
|
301
|
+
data.notes = clone(input.notes);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const operation = change.operation ?? change.op ?? "add";
|
|
305
|
+
if (!NOTE_OPERATIONS.has(operation))
|
|
306
|
+
fail("unsupported-note-operation", `Unsupported Note operation: ${operation}`, {
|
|
307
|
+
path: "note.operation",
|
|
308
|
+
});
|
|
309
|
+
const title = change.title;
|
|
310
|
+
const index = data.notes.findIndex((note) => note.title === title);
|
|
311
|
+
if (operation === "add") {
|
|
312
|
+
if (index >= 0) fail("duplicate-note", `Note already exists: ${title}`);
|
|
313
|
+
assertSingleLineTitle(title, "note.title");
|
|
314
|
+
data.notes.push({ title, source: ensureString(change.source), body: ensureString(change.body) });
|
|
315
|
+
} else if (index < 0) fail("missing-note", `Note does not exist: ${title}`);
|
|
316
|
+
else if (operation === "remove") data.notes.splice(index, 1);
|
|
317
|
+
else {
|
|
318
|
+
if (change.source !== undefined && typeof change.source !== "string")
|
|
319
|
+
fail("invalid-input-type", "Note source requires a string", { path: "note.source" });
|
|
320
|
+
if (change.body !== undefined && typeof change.body !== "string")
|
|
321
|
+
fail("invalid-input-type", "Note body requires a string", { path: "note.body" });
|
|
322
|
+
data.notes[index] = {
|
|
323
|
+
title,
|
|
324
|
+
source: change.source === undefined ? data.notes[index].source : change.source,
|
|
325
|
+
body: change.body === undefined ? data.notes[index].body : change.body,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function applyCurrentSlicePatch(data, input) {
|
|
331
|
+
const patch = input.currentSlice;
|
|
332
|
+
if (patch === undefined) return;
|
|
333
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch))
|
|
334
|
+
fail("invalid-input-type", "currentSlice patch requires an object");
|
|
335
|
+
if (!data.currentWork.currentSlice) fail("invalid-operation", "Use start to create a Current Slice");
|
|
336
|
+
assertSliceListInputs(patch, "currentSlice");
|
|
337
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
338
|
+
if (field === "id") fail("caller-supplied-id", "Current Slice IDs are script-owned");
|
|
339
|
+
if (field === "state") {
|
|
340
|
+
if (value !== data.currentWork.currentSlice.state)
|
|
341
|
+
fail("invalid-transition", "Use block or resume for Current Slice state transitions");
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
assertPreciseValue(field, value, `currentSlice.${field}`);
|
|
345
|
+
}
|
|
346
|
+
if (patch.state && patch.state !== data.currentWork.currentSlice.state)
|
|
347
|
+
fail("invalid-transition", "Use block or resume for Current Slice state transitions");
|
|
348
|
+
const current = data.currentWork.currentSlice;
|
|
349
|
+
const normalized = normalizeSlice({ ...current, ...patch, id: current.id }, current.id);
|
|
350
|
+
normalized.id = current.id;
|
|
351
|
+
normalized.state = current.state;
|
|
352
|
+
assertCompleteSlice(normalized, "update");
|
|
353
|
+
data.currentWork.currentSlice = normalized;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function preciseFieldType(field) {
|
|
357
|
+
if (PRECISE_LIST_FIELDS.has(field)) return "array";
|
|
358
|
+
if (PRECISE_STRING_FIELDS.has(field)) return "string";
|
|
359
|
+
if (field === "blocker") return "object-or-string";
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function assertPreciseValue(field, value, path) {
|
|
364
|
+
if (PRECISE_FORBIDDEN_FIELDS.has(field))
|
|
365
|
+
fail("immutable-edit-field", `Field ${field} is controlled by a dedicated transition`, { path });
|
|
366
|
+
const type = preciseFieldType(field);
|
|
367
|
+
if (!type) fail("unknown-edit-field", `Unknown editable field: ${field}`, { path });
|
|
368
|
+
if (type === "string" && typeof value !== "string")
|
|
369
|
+
fail("invalid-edit-type", `Field ${field} requires a string`, { path, expected: "string" });
|
|
370
|
+
if (type === "array" && (!Array.isArray(value) || value.some((item) => typeof item !== "string")))
|
|
371
|
+
fail("invalid-edit-type", `Field ${field} requires an array of strings`, { path, expected: "string[]" });
|
|
372
|
+
if (field === "type" && !SLICE_TYPES.has(value)) fail("invalid-slice-type", `Invalid slice type: ${value}`, { path });
|
|
373
|
+
if (
|
|
374
|
+
field === "blocker" &&
|
|
375
|
+
value !== null &&
|
|
376
|
+
typeof value !== "string" &&
|
|
377
|
+
(typeof value !== "object" || Array.isArray(value))
|
|
378
|
+
)
|
|
379
|
+
fail("invalid-edit-type", "Field blocker requires an object, string, or null", { path });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function entityCollections(data, kind) {
|
|
383
|
+
if (kind === "proposal") return [{ collection: data.proposals, label: "proposals" }];
|
|
384
|
+
if (kind === "decision") return [{ collection: data.history.decisions, label: "history.decisions" }];
|
|
385
|
+
if (kind === "checkpoint") return [{ collection: data.history.checkpoints, label: "history.checkpoints" }];
|
|
386
|
+
if (kind === "note") return [{ collection: data.notes, label: "notes" }];
|
|
387
|
+
if (kind === "slice")
|
|
388
|
+
return [{ collection: data.history.slices, label: "history.slices" }].concat(
|
|
389
|
+
data.currentWork.currentSlice ? [{ collection: [data.currentWork.currentSlice], label: "currentSlice" }] : [],
|
|
390
|
+
);
|
|
391
|
+
if (kind === "currentSlice")
|
|
392
|
+
return data.currentWork.currentSlice
|
|
393
|
+
? [{ collection: [data.currentWork.currentSlice], label: "currentSlice" }]
|
|
394
|
+
: [];
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function selectPreciseEntity(data, target, path) {
|
|
399
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
400
|
+
fail("invalid-edit-target", "Entity edits require an object target", { path });
|
|
401
|
+
const kind = target.kind;
|
|
402
|
+
const collections = entityCollections(data, kind);
|
|
403
|
+
if (!collections) fail("invalid-edit-target", `Unknown entity kind: ${kind}`, { path });
|
|
404
|
+
if (kind === "currentSlice" && !target.id && !target.title)
|
|
405
|
+
return { entity: collections[0]?.collection[0], label: "currentSlice" };
|
|
406
|
+
const matches = [];
|
|
407
|
+
for (const { collection, label } of collections) {
|
|
408
|
+
for (const entity of collection) {
|
|
409
|
+
if (
|
|
410
|
+
(target.id !== undefined && entity.id === target.id) ||
|
|
411
|
+
(target.title !== undefined && entity.title === target.title)
|
|
412
|
+
)
|
|
413
|
+
matches.push({ entity, label });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (!matches.length) fail("missing-entity", `No ${kind} matched the requested selector`, { path, target });
|
|
417
|
+
if (matches.length > 1)
|
|
418
|
+
fail("ambiguous-entity", `Entity selector matched multiple ${kind} entities`, { path, target });
|
|
419
|
+
return matches[0];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function fieldPathTarget(data, target, path) {
|
|
423
|
+
if (typeof target !== "string") return null;
|
|
424
|
+
const parts = target.split(".");
|
|
425
|
+
if (parts.length !== 2) fail("invalid-edit-target", `Field target must use section.field: ${target}`, { path });
|
|
426
|
+
const [section, field] = parts;
|
|
427
|
+
const owners = {
|
|
428
|
+
currentContext: data.currentContext,
|
|
429
|
+
currentWork: data.currentWork,
|
|
430
|
+
currentSlice: data.currentWork.currentSlice,
|
|
431
|
+
};
|
|
432
|
+
const owner = owners[section];
|
|
433
|
+
if (!owner) fail("invalid-edit-target", `Unknown field target section: ${section}`, { path });
|
|
434
|
+
if (section === "currentSlice" && !owner) fail("missing-entity", "There is no Current Slice to edit", { path });
|
|
435
|
+
return { owner, field, path: target };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function setPreciseField(owner, field, value, path) {
|
|
439
|
+
assertPreciseValue(field, value, path);
|
|
440
|
+
owner[field] = clone(value);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function clearPreciseField(owner, field, path) {
|
|
444
|
+
if (PRECISE_FORBIDDEN_FIELDS.has(field))
|
|
445
|
+
fail("immutable-edit-field", `Field ${field} is controlled by a dedicated transition`, { path });
|
|
446
|
+
if (!preciseFieldType(field)) fail("unknown-edit-field", `Unknown editable field: ${field}`, { path });
|
|
447
|
+
delete owner[field];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function replacePreciseText(owner, field, replacement, path) {
|
|
451
|
+
if (
|
|
452
|
+
!replacement ||
|
|
453
|
+
typeof replacement !== "object" ||
|
|
454
|
+
typeof replacement.old !== "string" ||
|
|
455
|
+
typeof replacement.new !== "string"
|
|
456
|
+
)
|
|
457
|
+
fail("invalid-edit-type", "replaceText requires { old, new } strings", { path });
|
|
458
|
+
const current = owner[field];
|
|
459
|
+
if (typeof current !== "string") fail("invalid-edit-type", `replaceText requires a string field: ${field}`, { path });
|
|
460
|
+
const oldText = normalizeLineEndings(replacement.old);
|
|
461
|
+
const newText = normalizeLineEndings(replacement.new);
|
|
462
|
+
const matches = current.split(oldText).length - 1;
|
|
463
|
+
if (matches !== 1)
|
|
464
|
+
fail("ambiguous-text-replacement", `Expected exactly one match in ${field}, found ${matches}`, { path });
|
|
465
|
+
owner[field] = current.replace(oldText, newText);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function applyListChange(owner, operation, field, values, path) {
|
|
469
|
+
if (!PRECISE_LIST_FIELDS.has(field)) fail("invalid-list-field", `Field ${field} is not an editable list`, { path });
|
|
470
|
+
if (!Array.isArray(values) || values.some((item) => typeof item !== "string"))
|
|
471
|
+
fail("invalid-edit-type", `List operation ${operation} requires an array of strings`, { path });
|
|
472
|
+
if (!isSingleLineList(values)) fail("invalid-list-member", "List members must be single-line strings", { path });
|
|
473
|
+
const current = Array.isArray(owner[field]) ? [...owner[field]] : [];
|
|
474
|
+
if (operation === "add") {
|
|
475
|
+
for (const value of values) {
|
|
476
|
+
if (current.includes(value)) fail("duplicate-list-member", `List ${field} already contains: ${value}`, { path });
|
|
477
|
+
current.push(value);
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
for (const value of values) {
|
|
481
|
+
const index = current.indexOf(value);
|
|
482
|
+
if (index < 0) fail("missing-list-member", `List ${field} does not contain: ${value}`, { path });
|
|
483
|
+
current.splice(index, 1);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
owner[field] = current;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function applyFieldMap(owner, fields, operation, path) {
|
|
490
|
+
if (fields === undefined) return;
|
|
491
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields))
|
|
492
|
+
fail("invalid-edit-type", `${operation} must be an object of fields`, { path });
|
|
493
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
494
|
+
const fieldPath = `${path}.${field}`;
|
|
495
|
+
if (operation === "set") setPreciseField(owner, field, value, fieldPath);
|
|
496
|
+
else if (operation === "clear") clearPreciseField(owner, field, fieldPath);
|
|
497
|
+
else if (operation === "replaceText") replacePreciseText(owner, field, value, fieldPath);
|
|
498
|
+
else applyListChange(owner, operation, field, value, fieldPath);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function moveProposal(data, title, destination, direction, path) {
|
|
503
|
+
const from = data.proposals.findIndex((proposal) => proposal.title === title);
|
|
504
|
+
if (from < 0) fail("missing-proposal", `Proposal does not exist: ${title}`, { path });
|
|
505
|
+
const to = data.proposals.findIndex((proposal) => proposal.title === destination);
|
|
506
|
+
if (to < 0) fail("missing-proposal", `Destination proposal does not exist: ${destination}`, { path });
|
|
507
|
+
const [proposal] = data.proposals.splice(from, 1);
|
|
508
|
+
const adjusted = from < to ? to - 1 : to;
|
|
509
|
+
data.proposals.splice(direction === "before" ? adjusted : adjusted + 1, 0, proposal);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function applyPreciseEntityEdit(data, edit, path) {
|
|
513
|
+
const allowed = new Set([
|
|
514
|
+
"target",
|
|
515
|
+
"set",
|
|
516
|
+
"clear",
|
|
517
|
+
"replaceText",
|
|
518
|
+
"add",
|
|
519
|
+
"remove",
|
|
520
|
+
"rename",
|
|
521
|
+
"moveBefore",
|
|
522
|
+
"moveAfter",
|
|
523
|
+
]);
|
|
524
|
+
for (const key of Object.keys(edit))
|
|
525
|
+
if (!allowed.has(key)) fail("unknown-edit-operation", `Unknown edit operation: ${key}`, { path });
|
|
526
|
+
const selected = selectPreciseEntity(data, edit.target, `${path}.target`);
|
|
527
|
+
const entity = selected.entity;
|
|
528
|
+
if (!entity) fail("missing-entity", "There is no Current Slice to edit", { path });
|
|
529
|
+
if (edit.clear !== undefined && !Array.isArray(edit.clear))
|
|
530
|
+
fail("invalid-edit-type", "clear requires an array of field names", { path: `${path}.clear` });
|
|
531
|
+
if (edit.rename !== undefined) {
|
|
532
|
+
assertSingleLineTitle(edit.rename, `${path}.rename`);
|
|
533
|
+
entity.title = edit.rename;
|
|
534
|
+
}
|
|
535
|
+
applyFieldMap(entity, edit.set, "set", `${path}.set`);
|
|
536
|
+
if (Array.isArray(edit.clear)) for (const field of edit.clear) clearPreciseField(entity, field, `${path}.clear`);
|
|
537
|
+
applyFieldMap(entity, edit.replaceText, "replaceText", `${path}.replaceText`);
|
|
538
|
+
applyFieldMap(entity, edit.add, "add", `${path}.add`);
|
|
539
|
+
applyFieldMap(entity, edit.remove, "remove", `${path}.remove`);
|
|
540
|
+
if (edit.moveBefore !== undefined || edit.moveAfter !== undefined) {
|
|
541
|
+
if (edit.target.kind !== "proposal") fail("invalid-edit-operation", "Only proposals can be reordered", { path });
|
|
542
|
+
moveProposal(
|
|
543
|
+
data,
|
|
544
|
+
entity.title,
|
|
545
|
+
edit.moveBefore ?? edit.moveAfter,
|
|
546
|
+
edit.moveBefore === undefined ? "after" : "before",
|
|
547
|
+
path,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function applyPreciseFieldEdit(data, edit, path) {
|
|
553
|
+
const allowed = new Set(["target", "set", "clear", "replaceText"]);
|
|
554
|
+
for (const key of Object.keys(edit))
|
|
555
|
+
if (!allowed.has(key)) fail("unknown-edit-operation", `Unknown edit operation: ${key}`, { path });
|
|
556
|
+
const target = fieldPathTarget(data, edit.target, `${path}.target`);
|
|
557
|
+
if (!target) fail("invalid-edit-target", "Unsupported edit target", { path });
|
|
558
|
+
const operations = [edit.set !== undefined, edit.clear !== undefined, edit.replaceText !== undefined].filter(Boolean);
|
|
559
|
+
if (operations.length > 1) fail("multiple-edit-operations", "A field edit must choose one operation", { path });
|
|
560
|
+
if (edit.clear !== undefined && edit.clear !== true)
|
|
561
|
+
fail("invalid-edit-type", "clear requires the boolean true", { path: `${path}.clear` });
|
|
562
|
+
if (edit.set !== undefined) {
|
|
563
|
+
if (typeof edit.set === "object" && !Array.isArray(edit.set))
|
|
564
|
+
fail("invalid-edit-type", "Field target set requires one scalar value", { path });
|
|
565
|
+
setPreciseField(target.owner, target.field, edit.set, `${path}.set`);
|
|
566
|
+
}
|
|
567
|
+
if (edit.clear === true) clearPreciseField(target.owner, target.field, `${path}.clear`);
|
|
568
|
+
if (edit.replaceText !== undefined)
|
|
569
|
+
replacePreciseText(target.owner, target.field, edit.replaceText, `${path}.replaceText`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function applyPreciseCollectionEdit(data, edit, path) {
|
|
573
|
+
const affectedIds = [];
|
|
574
|
+
const allowed = new Set(["target", "addEntity", "removeEntity"]);
|
|
575
|
+
for (const key of Object.keys(edit))
|
|
576
|
+
if (!allowed.has(key)) fail("unknown-edit-operation", `Unknown edit operation: ${key}`, { path });
|
|
577
|
+
const hasAdd = edit.addEntity !== undefined;
|
|
578
|
+
const hasRemove = edit.removeEntity !== undefined;
|
|
579
|
+
if (hasAdd === hasRemove)
|
|
580
|
+
fail("multiple-edit-operations", "A collection edit must choose exactly one operation", { path });
|
|
581
|
+
const collectionName = edit.target?.kind ?? edit.target?.collection;
|
|
582
|
+
if (
|
|
583
|
+
collectionName !== "proposals" &&
|
|
584
|
+
collectionName !== "decisions" &&
|
|
585
|
+
collectionName !== "checkpoints" &&
|
|
586
|
+
collectionName !== "notes"
|
|
587
|
+
)
|
|
588
|
+
fail("invalid-edit-target", `Unknown editable collection: ${collectionName}`, { path });
|
|
589
|
+
const collection =
|
|
590
|
+
collectionName === "proposals"
|
|
591
|
+
? data.proposals
|
|
592
|
+
: collectionName === "decisions"
|
|
593
|
+
? data.history.decisions
|
|
594
|
+
: collectionName === "checkpoints"
|
|
595
|
+
? data.history.checkpoints
|
|
596
|
+
: data.notes;
|
|
597
|
+
if (edit.addEntity !== undefined) {
|
|
598
|
+
const value = edit.addEntity;
|
|
599
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
600
|
+
fail("invalid-edit-type", "addEntity requires an object", { path });
|
|
601
|
+
if (collectionName === "proposals") {
|
|
602
|
+
assertSingleLineTitle(value.title, `${path}.addEntity.title`);
|
|
603
|
+
if (collection.some((item) => item.title === value.title))
|
|
604
|
+
fail("duplicate-proposal", `Proposal already exists: ${value.title}`, { path });
|
|
605
|
+
if (!SLICE_TYPES.has(value.type)) fail("invalid-proposal-type", `Invalid proposal type: ${value.type}`, { path });
|
|
606
|
+
assertPreciseValue("intendedResult", value.intendedResult, `${path}.addEntity.intendedResult`);
|
|
607
|
+
assertPreciseValue("expectedEvidence", value.expectedEvidence, `${path}.addEntity.expectedEvidence`);
|
|
608
|
+
assertSliceListInputs(value, `${path}.addEntity`);
|
|
609
|
+
const proposal = normalizeProposal(value);
|
|
610
|
+
assertProposalContent(proposal, `${path}.addEntity`);
|
|
611
|
+
collection.push(proposal);
|
|
612
|
+
} else if (collectionName === "decisions") {
|
|
613
|
+
if (value.id !== undefined) fail("caller-supplied-id", "The script assigns decision IDs", { path });
|
|
614
|
+
assertNoCallerControlledDecisionFields(value, `${path}.addEntity`);
|
|
615
|
+
assertSingleLineTitle(value.title, `${path}.addEntity.title`);
|
|
616
|
+
assertDecisionContent(value, `${path}.addEntity`);
|
|
617
|
+
assertDecisionReferences(value, `${path}.addEntity`);
|
|
618
|
+
const decision = normalizeDecision(value, nextId(data, "D"));
|
|
619
|
+
collection.push(decision);
|
|
620
|
+
affectedIds.push(decision.id);
|
|
621
|
+
} else if (collectionName === "checkpoints") {
|
|
622
|
+
assertSingleLineTitle(value.title, `${path}.addEntity.title`);
|
|
623
|
+
if (typeof value.type !== "string" || !value.type.trim())
|
|
624
|
+
fail("missing-checkpoint-field", "Checkpoint type is required", { path: `${path}.addEntity.type` });
|
|
625
|
+
for (const field of ["selectedBy", "condition", "result"])
|
|
626
|
+
assertPreciseValue(field, value[field], `${path}.addEntity.${field}`);
|
|
627
|
+
if (collection.some((item) => item.title === value.title))
|
|
628
|
+
fail("duplicate-checkpoint", `Checkpoint already exists: ${value.title}`, { path });
|
|
629
|
+
collection.push(normalizeCheckpoint(value));
|
|
630
|
+
} else {
|
|
631
|
+
assertSingleLineTitle(value.title, `${path}.addEntity.title`);
|
|
632
|
+
if (typeof value.body !== "string") fail("invalid-edit-type", "Note body requires a string", { path });
|
|
633
|
+
collection.push({
|
|
634
|
+
title: value.title,
|
|
635
|
+
source: typeof value.source === "string" ? value.source : "",
|
|
636
|
+
body: value.body,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (edit.removeEntity !== undefined) {
|
|
641
|
+
if (collectionName === "decisions")
|
|
642
|
+
fail("decision-removal-requires-lifecycle", "Retire or supersede decisions through the decision operation");
|
|
643
|
+
const selector = edit.removeEntity;
|
|
644
|
+
if (!selector || typeof selector !== "object")
|
|
645
|
+
fail("invalid-edit-target", "removeEntity requires an id or title selector", { path });
|
|
646
|
+
const matches = collection
|
|
647
|
+
.map((item, index) => ({ item, index }))
|
|
648
|
+
.filter(
|
|
649
|
+
({ item }) =>
|
|
650
|
+
(selector.id !== undefined && item.id === selector.id) ||
|
|
651
|
+
(selector.title !== undefined && item.title === selector.title),
|
|
652
|
+
);
|
|
653
|
+
if (!matches.length) fail("missing-entity", `No ${collectionName} matched the remove selector`, { path });
|
|
654
|
+
if (matches.length > 1) fail("ambiguous-entity", `Remove selector matched multiple ${collectionName}`, { path });
|
|
655
|
+
if (matches[0].item.id) affectedIds.push(matches[0].item.id);
|
|
656
|
+
collection.splice(matches[0].index, 1);
|
|
657
|
+
}
|
|
658
|
+
return affectedIds;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function applyPreciseEdits(data, edits) {
|
|
662
|
+
const affectedIds = [];
|
|
663
|
+
if (!Array.isArray(edits) || edits.length === 0) fail("missing-edits", "update requires a non-empty edits array");
|
|
664
|
+
for (const [index, edit] of edits.entries()) {
|
|
665
|
+
if (!edit || typeof edit !== "object" || Array.isArray(edit))
|
|
666
|
+
fail("invalid-edit", `Edit ${index} must be an object`);
|
|
667
|
+
if (edit.addEntity !== undefined || edit.removeEntity !== undefined)
|
|
668
|
+
affectedIds.push(...applyPreciseCollectionEdit(data, edit, `edits[${index}]`));
|
|
669
|
+
else if (typeof edit.target === "string") applyPreciseFieldEdit(data, edit, `edits[${index}]`);
|
|
670
|
+
else applyPreciseEntityEdit(data, edit, `edits[${index}]`);
|
|
671
|
+
if (edit.target?.id) affectedIds.push(edit.target.id);
|
|
672
|
+
}
|
|
673
|
+
return { affectedIds: [...new Set(affectedIds)] };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function assertKnownUpdateInput(input) {
|
|
677
|
+
for (const key of Object.keys(input))
|
|
678
|
+
if (!UPDATE_INPUT_KEYS.has(key))
|
|
679
|
+
fail("unknown-input-field", `Unknown update input field: ${key}`, { path: `input.${key}` });
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function assertNoMixedEditInput(input) {
|
|
683
|
+
const mixedFields = Object.keys(input).filter((key) => !EDIT_MODE_INPUT_KEYS.has(key));
|
|
684
|
+
if (mixedFields.length)
|
|
685
|
+
fail("mixed-update-input", "The edits form cannot be combined with direct update fields", {
|
|
686
|
+
fields: mixedFields,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function applyUpdate(data, input) {
|
|
691
|
+
assertKnownUpdateInput(input);
|
|
692
|
+
if (input.edits !== undefined) {
|
|
693
|
+
assertNoMixedEditInput(input);
|
|
694
|
+
const operation = applyPreciseEdits(data, input.edits);
|
|
695
|
+
applyTaskState(data, input);
|
|
696
|
+
return operation;
|
|
697
|
+
}
|
|
698
|
+
if (Array.isArray(input.proposals))
|
|
699
|
+
fail(
|
|
700
|
+
"collection-replacement-requires-edits",
|
|
701
|
+
"Use explicit edits with a target and operation instead of replacing proposals",
|
|
702
|
+
);
|
|
703
|
+
if (Array.isArray(input.notes))
|
|
704
|
+
fail(
|
|
705
|
+
"collection-replacement-requires-edits",
|
|
706
|
+
"Use explicit edits with a target and operation instead of replacing notes",
|
|
707
|
+
);
|
|
708
|
+
applyContextPatch(data, input);
|
|
709
|
+
applyCurrentSlicePatch(data, input);
|
|
710
|
+
applyDecisionChange(data, input);
|
|
711
|
+
applyProposalChange(data, input);
|
|
712
|
+
applyNoteChange(data, input);
|
|
713
|
+
applyTaskState(data, input);
|
|
714
|
+
if (input.start || input.close || input.block || input.resume)
|
|
715
|
+
fail("invalid-operation", "Use the dedicated transition command instead of update");
|
|
716
|
+
return { affectedIds: [] };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function assertActiveTask(data, operation) {
|
|
720
|
+
if (data.taskState !== "Active")
|
|
721
|
+
fail("invalid-transition", `${operation} requires an Active task; current state is ${data.taskState}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function assertExpectedCurrentSlice(data, input) {
|
|
725
|
+
const current = data.currentWork.currentSlice;
|
|
726
|
+
if (!current) fail("missing-current-slice", "No Current Slice exists");
|
|
727
|
+
const id = input.sliceId ?? input.id ?? input.currentSliceId;
|
|
728
|
+
if (id && current.id !== id) fail("slice-mismatch", `Requested slice ${id} is not the Current Slice ${current.id}`);
|
|
729
|
+
return current;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function applyStart(data, input) {
|
|
733
|
+
assertKnownCommandInput(input, "start");
|
|
734
|
+
assertSliceListInputs(input, "start");
|
|
735
|
+
assertActiveTask(data, "start");
|
|
736
|
+
if (data.currentWork.currentSlice) fail("existing-current-slice", "A Current Slice already exists");
|
|
737
|
+
if (input.id !== undefined) fail("caller-supplied-id", "The script assigns Current Slice IDs");
|
|
738
|
+
const proposalTitle = input.proposalTitle ?? input.proposal;
|
|
739
|
+
let source = {};
|
|
740
|
+
if (proposalTitle) {
|
|
741
|
+
const index = data.proposals.findIndex((proposal) => proposal.title === proposalTitle);
|
|
742
|
+
if (index < 0) fail("missing-proposal", `Proposal does not exist: ${proposalTitle}`);
|
|
743
|
+
source = data.proposals[index];
|
|
744
|
+
data.proposals.splice(index, 1);
|
|
745
|
+
}
|
|
746
|
+
const slice = normalizeSlice(
|
|
747
|
+
{
|
|
748
|
+
...source,
|
|
749
|
+
...input,
|
|
750
|
+
title: input.title ?? source.title,
|
|
751
|
+
authoritySource: input.authoritySource ?? input.authority,
|
|
752
|
+
},
|
|
753
|
+
nextId(data, "S"),
|
|
754
|
+
);
|
|
755
|
+
assertSingleLineTitle(slice.title, "start.title");
|
|
756
|
+
if (!slice.authoritySource) fail("missing-authority", "start requires authoritySource");
|
|
757
|
+
if (!SLICE_TYPES.has(slice.type)) fail("invalid-slice-type", `Invalid slice type: ${slice.type}`);
|
|
758
|
+
assertCompleteSlice(slice, "start");
|
|
759
|
+
slice.state = "In progress";
|
|
760
|
+
data.currentWork.currentSlice = slice;
|
|
761
|
+
return { affectedIds: [slice.id] };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function applyBlock(data, input) {
|
|
765
|
+
assertKnownCommandInput(input, "block");
|
|
766
|
+
const slice = assertExpectedCurrentSlice(data, input);
|
|
767
|
+
if (slice.state !== "In progress") fail("invalid-transition", "Only an In progress slice can be blocked");
|
|
768
|
+
const blocker = input.blocker ?? {
|
|
769
|
+
blocker: input.reason ?? input.what,
|
|
770
|
+
why: input.why,
|
|
771
|
+
required: input.required ?? input.requiredResolution,
|
|
772
|
+
};
|
|
773
|
+
if (!blocker || typeof blocker !== "object" || !blocker.blocker || !blocker.why || !blocker.required)
|
|
774
|
+
fail("missing-blocker", "block requires blocker, why, and required resolution");
|
|
775
|
+
slice.state = "Blocked";
|
|
776
|
+
slice.blocker = clone(blocker);
|
|
777
|
+
slice.resumeWhen = ensureString(input.resumeWhen);
|
|
778
|
+
data.currentWork.blockers = [blocker.blocker];
|
|
779
|
+
return { affectedIds: [slice.id] };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function applyResume(data, input) {
|
|
783
|
+
assertKnownCommandInput(input, "resume");
|
|
784
|
+
assertSliceListInputs(input, "resume");
|
|
785
|
+
assertActiveTask(data, "resume");
|
|
786
|
+
const slice = assertExpectedCurrentSlice(data, input);
|
|
787
|
+
if (slice.state !== "Blocked") fail("invalid-transition", "Only a Blocked slice can be resumed");
|
|
788
|
+
const resolution = input.resolutionSource ?? input.resolvedBy;
|
|
789
|
+
if (!resolution) fail("missing-resolution", "resume requires resolutionSource");
|
|
790
|
+
slice.blockerHistory = [
|
|
791
|
+
...ensureArray(slice.blockerHistory),
|
|
792
|
+
JSON.stringify({ blocker: slice.blocker, resolutionSource: resolution }),
|
|
793
|
+
];
|
|
794
|
+
slice.blocker = null;
|
|
795
|
+
slice.resumeWhen = "";
|
|
796
|
+
slice.state = "In progress";
|
|
797
|
+
data.currentWork.blockers = [];
|
|
798
|
+
if (input.evidence !== undefined) slice.evidence = ensureArray(input.evidence);
|
|
799
|
+
return { affectedIds: [slice.id] };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function applyReopen(data, input) {
|
|
803
|
+
assertKnownCommandInput(input, "reopen");
|
|
804
|
+
assertSliceListInputs(input, "reopen");
|
|
805
|
+
assertActiveTask(data, "reopen");
|
|
806
|
+
if (data.currentWork.currentSlice) fail("existing-current-slice", "A Current Slice already exists");
|
|
807
|
+
const sliceId = input.sliceId ?? input.id ?? input.currentSliceId;
|
|
808
|
+
if (!sliceId) fail("missing-slice-id", "reopen requires sliceId");
|
|
809
|
+
const historyIndex = data.history.slices.findIndex((slice) => slice.id === sliceId);
|
|
810
|
+
if (historyIndex < 0) fail("missing-historical-slice", `No historical slice exists with ID ${sliceId}`);
|
|
811
|
+
const historical = data.history.slices[historyIndex];
|
|
812
|
+
if (!HISTORICAL_SLICE_STATES.has(historical.state))
|
|
813
|
+
fail("invalid-transition", "Only a historical slice can be reopened");
|
|
814
|
+
const authority = input.authoritySource ?? input.authority ?? input.userDirection;
|
|
815
|
+
if (!authority) fail("missing-authority", "reopen requires authoritySource");
|
|
816
|
+
const reason = input.reopenReason ?? input.reason;
|
|
817
|
+
if (!reason) fail("missing-reopen-reason", "reopen requires reopenReason");
|
|
818
|
+
const reasonAndScope = input.reasonAndScope ?? input.scopeChange ?? input.scope;
|
|
819
|
+
if (!reasonAndScope) fail("missing-reopen-scope", "reopen requires reasonAndScope");
|
|
820
|
+
const expectedEvidence = input.expectedEvidence;
|
|
821
|
+
if (!expectedEvidence) fail("missing-reopen-evidence", "reopen requires expectedEvidence");
|
|
822
|
+
const stopCondition = input.stopCondition;
|
|
823
|
+
if (!stopCondition) fail("missing-reopen-stop-condition", "reopen requires stopCondition");
|
|
824
|
+
const reopenHistory = [
|
|
825
|
+
...ensureArray(historical.reopenHistory),
|
|
826
|
+
JSON.stringify({
|
|
827
|
+
priorState: historical.state,
|
|
828
|
+
priorOutcome: historical.outcome,
|
|
829
|
+
priorEvidence: historical.evidence,
|
|
830
|
+
priorBlocker: historical.blocker,
|
|
831
|
+
priorTaskEffect: historical.taskEffect,
|
|
832
|
+
priorAbandonmentReason: historical.abandonmentReason,
|
|
833
|
+
authoritySource: authority,
|
|
834
|
+
reopenReason: reason,
|
|
835
|
+
}),
|
|
836
|
+
];
|
|
837
|
+
const reopened = normalizeSlice(
|
|
838
|
+
{
|
|
839
|
+
id: historical.id,
|
|
840
|
+
title: historical.title,
|
|
841
|
+
state: "In progress",
|
|
842
|
+
type: historical.type,
|
|
843
|
+
intendedResult: historical.intendedResult,
|
|
844
|
+
authoritySource: authority,
|
|
845
|
+
reasonAndScope,
|
|
846
|
+
expectedEvidence,
|
|
847
|
+
stopCondition,
|
|
848
|
+
startingState: input.startingState,
|
|
849
|
+
acceptedExtensions: historical.acceptedExtensions,
|
|
850
|
+
dependencies: historical.dependencies,
|
|
851
|
+
selectedCheckpoints: input.selectedCheckpoints,
|
|
852
|
+
reopenHistory,
|
|
853
|
+
currentResult: input.currentResult,
|
|
854
|
+
evidence: input.evidence,
|
|
855
|
+
reviewSummary: input.reviewSummary,
|
|
856
|
+
taskEffect: input.taskEffect,
|
|
857
|
+
pendingBoundaries: input.pendingBoundaries,
|
|
858
|
+
pendingReviews: input.pendingReviews,
|
|
859
|
+
},
|
|
860
|
+
historical.id,
|
|
861
|
+
);
|
|
862
|
+
assertSingleLineTitle(reopened.title, "reopen.title");
|
|
863
|
+
assertCompleteSlice(reopened, "reopen");
|
|
864
|
+
data.history.slices.splice(historyIndex, 1);
|
|
865
|
+
data.currentWork.currentSlice = reopened;
|
|
866
|
+
data.currentWork.blockers = [];
|
|
867
|
+
if (input.currentRoute !== undefined || input.route !== undefined)
|
|
868
|
+
data.currentWork.route = input.currentRoute ?? input.route;
|
|
869
|
+
if (input.nextAction !== undefined || input.nextUsefulAction !== undefined)
|
|
870
|
+
data.currentWork.nextAction = input.nextAction ?? input.nextUsefulAction;
|
|
871
|
+
return { affectedIds: [reopened.id] };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function unresolvedSelectedCheckpoints(data, slice) {
|
|
875
|
+
const resolved = new Set(
|
|
876
|
+
data.history.checkpoints
|
|
877
|
+
.filter((checkpoint) => CHECKPOINT_RESULTS.has(checkpoint.result))
|
|
878
|
+
.map((checkpoint) => checkpoint.title),
|
|
879
|
+
);
|
|
880
|
+
return ensureArray(slice.selectedCheckpoints).filter((title) => !resolved.has(title));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function applyClose(data, input) {
|
|
884
|
+
assertKnownCommandInput(input, "close");
|
|
885
|
+
assertSliceListInputs(input, "close");
|
|
886
|
+
const slice = assertExpectedCurrentSlice(data, input);
|
|
887
|
+
const finalState = input.finalState ?? input.state;
|
|
888
|
+
if (!HISTORICAL_SLICE_STATES.has(finalState)) fail("invalid-close-state", `Invalid final slice state: ${finalState}`);
|
|
889
|
+
if (finalState === "Blocked" && slice.state !== "Blocked")
|
|
890
|
+
fail("invalid-transition", "Historical Blocked close requires a currently Blocked slice");
|
|
891
|
+
if (finalState === "Blocked" && !(input.authoritySource ?? input.parkAuthority))
|
|
892
|
+
fail("missing-authority", "Historical Blocked close requires deliberate park authority");
|
|
893
|
+
if (finalState === "Abandoned" && !(input.authoritySource ?? input.abandonmentAuthority))
|
|
894
|
+
fail("missing-authority", "Abandoned close requires authoritySource");
|
|
895
|
+
const outcome = ensureString(input.outcome ?? input.result ?? slice.result);
|
|
896
|
+
const evidence = ensureArray(input.evidence ?? slice.evidence);
|
|
897
|
+
if (!outcome.trim()) fail("missing-outcome", "close requires a compact settled outcome");
|
|
898
|
+
if (finalState === "Abandoned" && !(input.abandonmentReason ?? input.reason))
|
|
899
|
+
fail("missing-abandonment-reason", "Abandoned close requires abandonmentReason");
|
|
900
|
+
if (finalState === "Abandoned" && !(input.residualEffects ?? input.taskEffect))
|
|
901
|
+
fail("missing-residual-effects", "Abandoned close requires residualEffects or taskEffect");
|
|
902
|
+
if (!evidence.length) fail("missing-evidence", "close requires the strongest available evidence boundary");
|
|
903
|
+
if (finalState === "Completed") {
|
|
904
|
+
const pending = [
|
|
905
|
+
...ensureArray(slice.pendingBoundaries),
|
|
906
|
+
...ensureArray(slice.pendingReviews),
|
|
907
|
+
...unresolvedSelectedCheckpoints(data, slice),
|
|
908
|
+
];
|
|
909
|
+
if (pending.length)
|
|
910
|
+
fail("unresolved-settlement", "Completed close requires all pending boundaries to be reconciled", { pending });
|
|
911
|
+
if (slice.state === "Blocked")
|
|
912
|
+
fail("blocked-close", "A Blocked slice must be parked as historical Blocked or explicitly abandoned");
|
|
913
|
+
}
|
|
914
|
+
const entry = compactObject({
|
|
915
|
+
id: slice.id,
|
|
916
|
+
title: slice.title,
|
|
917
|
+
state: finalState,
|
|
918
|
+
type: slice.type,
|
|
919
|
+
intendedResult: slice.intendedResult,
|
|
920
|
+
authoritySource: slice.authoritySource,
|
|
921
|
+
acceptedExtensions: slice.acceptedExtensions.length ? slice.acceptedExtensions : undefined,
|
|
922
|
+
dependencies: slice.dependencies.length ? slice.dependencies : undefined,
|
|
923
|
+
outcome,
|
|
924
|
+
evidence,
|
|
925
|
+
reviewSummary: ensureString(input.reviewSummary ?? slice.reviewSummary) || undefined,
|
|
926
|
+
taskEffect: ensureString(input.residualEffects ?? input.taskEffect ?? slice.taskEffect) || undefined,
|
|
927
|
+
abandonmentReason:
|
|
928
|
+
finalState === "Abandoned" ? ensureString(input.abandonmentReason ?? input.reason) || undefined : undefined,
|
|
929
|
+
reopenHistory: slice.reopenHistory.length ? slice.reopenHistory : undefined,
|
|
930
|
+
blocker:
|
|
931
|
+
finalState === "Blocked"
|
|
932
|
+
? { blocker: slice.blocker, resumeWhen: slice.resumeWhen, required: slice.blocker?.required }
|
|
933
|
+
: undefined,
|
|
934
|
+
});
|
|
935
|
+
data.history.slices.push(entry);
|
|
936
|
+
data.currentWork.currentSlice = null;
|
|
937
|
+
data.currentWork.blockers = [];
|
|
938
|
+
if (input.currentContext) patchObject(data.currentContext, input.currentContext);
|
|
939
|
+
if (input.currentRoute !== undefined || input.route !== undefined)
|
|
940
|
+
data.currentWork.route = input.currentRoute ?? input.route;
|
|
941
|
+
if (input.nextAction !== undefined || input.nextUsefulAction !== undefined)
|
|
942
|
+
data.currentWork.nextAction = input.nextAction ?? input.nextUsefulAction;
|
|
943
|
+
if (input.taskState !== undefined) {
|
|
944
|
+
if (!input.taskStateAuthority && !input.authoritySource)
|
|
945
|
+
fail("missing-authority", "Post-close task-state changes require explicit authority");
|
|
946
|
+
if (!TASK_STATES.has(input.taskState)) fail("invalid-task-state", `Invalid task state: ${input.taskState}`);
|
|
947
|
+
if (
|
|
948
|
+
(finalState === "Completed" && input.taskState !== "Completed") ||
|
|
949
|
+
(finalState === "Abandoned" && input.taskState !== "Abandoned")
|
|
950
|
+
)
|
|
951
|
+
fail("inconsistent-task-state", "Post-close terminal task state must agree with the settled slice state");
|
|
952
|
+
data.taskState = input.taskState;
|
|
953
|
+
}
|
|
954
|
+
return { affectedIds: [slice.id] };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export function applyOperation(data, command, input) {
|
|
958
|
+
if (command === "update") return applyUpdate(data, input);
|
|
959
|
+
if (command === "start") return applyStart(data, input);
|
|
960
|
+
if (command === "block") return applyBlock(data, input);
|
|
961
|
+
if (command === "resume") return applyResume(data, input);
|
|
962
|
+
if (command === "reopen") return applyReopen(data, input);
|
|
963
|
+
if (command === "close") return applyClose(data, input);
|
|
964
|
+
fail("unknown-command", `Unsupported mutation command: ${command}`);
|
|
965
|
+
}
|