taskforce-loop-engineering 0.15.9 → 0.15.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/MIGRATING.md +4 -0
- package/README.md +14 -0
- package/bin/loop-engineering.mjs +40 -1
- package/docs/transactional-kernel-and-goal-api.md +25 -0
- package/lib/core.mjs +100 -30
- package/lib/goal-api.mjs +59 -0
- package/lib/transactional-state-kernel.mjs +158 -0
- package/package.json +9 -1
- package/scripts/competitive-acceptance.mjs +65 -0
- package/scripts/competitive-final-judgement.mjs +27 -0
- package/scripts/project-gate-reconciliation-self-test.mjs +124 -3
- package/scripts/route-notify-self-test.mjs +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.11 - 2026-08-28
|
|
6
|
+
|
|
7
|
+
- Add a transactional state kernel with typed effects, receipt chaining, CAS/fencing, exact-effect replay, crash recovery, and completion fencing.
|
|
8
|
+
- Add the public Goal API and five-command primary CLI while preserving advanced commands.
|
|
9
|
+
- Add public CI and seven competitive acceptance fixtures.
|
|
10
|
+
|
|
11
|
+
## 0.15.10 - 2026-08-28
|
|
12
|
+
|
|
13
|
+
- Materialize human-input gates only for permissions or external conditions that are explicitly missing and needed now; preserve authorized, consumed, future, and conditional boundaries as audit context instead of repeatedly blocking project progress.
|
|
14
|
+
- Honor project standing authorization for in-scope production backup, deploy, restart, readiness, restore, and rollback work while keeping publication, credential, destructive, and other excluded actions gated.
|
|
15
|
+
- Resolve checkpoint-bound subproject backlogs when deciding whether safe project work remains, with regression coverage for current blockers, dormant gates, and publication boundaries.
|
|
16
|
+
|
|
5
17
|
## 0.15.9 - 2026-08-25
|
|
6
18
|
|
|
7
19
|
- Clarify that the disposable OpenClaw smoke may write required checkpoint and verification artifacts only inside its temporary Loop runtime queue while user/project files, configuration, credentials, and external state remain read-only.
|
package/MIGRATING.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Migrating to Taskforce Loop Engineering 0.12.0
|
|
2
2
|
|
|
3
|
+
## Goal API and primary CLI
|
|
4
|
+
|
|
5
|
+
The five-command Goal interface is additive. Existing advanced commands and artifact formats are not removed or automatically rewritten. New integrations should prefer `init/run/status/review/doctor --id`; existing integrations may migrate incrementally. See `docs/transactional-kernel-and-goal-api.md`.
|
|
6
|
+
|
|
3
7
|
## Operator projection
|
|
4
8
|
|
|
5
9
|
No runtime artifact migration is required. P3 reads P0/P1/P2 and legacy queue/project artifacts in place and emits projection schema `1.0.0`; existing writers remain authoritative. Consumers should use `schema_version`, tolerate additive fields, and treat degraded health as a refresh/investigation signal. `dashboard-serve` is loopback-only unless `--allow-non-loopback` is explicit.
|
package/README.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Taskforce Loop Engineering
|
|
2
2
|
|
|
3
|
+
## Primary Goal interface
|
|
4
|
+
|
|
5
|
+
The ordinary public surface is `init`, `run`, `status`, `review`, and `doctor`:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
loop-engineering init --id demo --goal "Deliver the complete verified result"
|
|
9
|
+
loop-engineering run --id demo
|
|
10
|
+
loop-engineering status --id demo
|
|
11
|
+
loop-engineering review --id demo --decision revise --reason "change strategy"
|
|
12
|
+
loop-engineering doctor --id demo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Node.js callers can use `Goal.init/run/status/review/doctor`. Existing queue, project, revision, human-gate, reservation, dashboard, and worktree commands remain supported as advanced commands. See [the transactional kernel and migration guide](docs/transactional-kernel-and-goal-api.md).
|
|
16
|
+
|
|
3
17
|
[](https://github.com/ambitioncn/taskforce-loop-engineering/actions/workflows/production-trust.yml)
|
|
4
18
|
|
|
5
19
|
## Platform-neutral adapter SDK
|
package/bin/loop-engineering.mjs
CHANGED
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
exportDashboard,
|
|
101
101
|
filterProjection
|
|
102
102
|
} from '../lib/operator-dashboard.mjs';
|
|
103
|
+
import { doctorGoal, initGoal, reviewGoal, runGoal, statusGoal } from '../lib/goal-api.mjs';
|
|
103
104
|
|
|
104
105
|
function parseArgs(argv) {
|
|
105
106
|
const args = { _: [], root: process.cwd(), json: false, force: false };
|
|
@@ -1605,9 +1606,16 @@ async function writeRevisionDriftAllowTemplateOutput(root, template, output, opt
|
|
|
1605
1606
|
return { file: path.relative(root, outputFile), format: 'json' };
|
|
1606
1607
|
}
|
|
1607
1608
|
|
|
1608
|
-
const HELP = `loop-engineering -
|
|
1609
|
+
const HELP = `loop-engineering - durable Goal loops
|
|
1609
1610
|
|
|
1610
1611
|
Usage:
|
|
1612
|
+
loop-engineering init --id goal-id --goal "Terminal goal" [--root <workspace>] [--json]
|
|
1613
|
+
loop-engineering run --id goal-id [--root <workspace>] [--json]
|
|
1614
|
+
loop-engineering status --id goal-id [--root <workspace>] [--json]
|
|
1615
|
+
loop-engineering review --id goal-id --decision accept|revise|wait [--reason text] [--root <workspace>] [--json]
|
|
1616
|
+
loop-engineering doctor [--id goal-id] [--root <workspace>] [--json]
|
|
1617
|
+
|
|
1618
|
+
Advanced compatibility commands:
|
|
1611
1619
|
loop-engineering init [--root <workspace>] [--force]
|
|
1612
1620
|
loop-engineering run --config configs/loops/name.json [--root <workspace>] [--json]
|
|
1613
1621
|
loop-engineering verify [--config configs/loops/name.json] [--root <workspace>]
|
|
@@ -1694,6 +1702,11 @@ Exit codes:
|
|
|
1694
1702
|
1 invalid spec, command error outside a check, or runtime failure`;
|
|
1695
1703
|
|
|
1696
1704
|
async function runCommand(args) {
|
|
1705
|
+
if (args.id && !args.config) {
|
|
1706
|
+
const result = await runGoal(args.root, args.id, { triggerId: args.sourceMessageId ?? 'manual' });
|
|
1707
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1708
|
+
return 0;
|
|
1709
|
+
}
|
|
1697
1710
|
if (!args.config) throw new Error('run requires --config.');
|
|
1698
1711
|
const root = args.root;
|
|
1699
1712
|
const { spec, file: specPath } = await loadSpec(root, args.config);
|
|
@@ -1787,6 +1800,10 @@ async function verifyCommand(args) {
|
|
|
1787
1800
|
}
|
|
1788
1801
|
|
|
1789
1802
|
async function statusCommand(args) {
|
|
1803
|
+
if (args.id && !args.config) {
|
|
1804
|
+
console.log(JSON.stringify(await statusGoal(args.root, args.id), null, 2));
|
|
1805
|
+
return 0;
|
|
1806
|
+
}
|
|
1790
1807
|
const files = await configFilesFromArgs(args.root, args.config ? ['--config', args.config] : []);
|
|
1791
1808
|
if (files.length === 0) throw new Error('No loop configs found.');
|
|
1792
1809
|
const reports = [];
|
|
@@ -1854,6 +1871,11 @@ async function summarizeCommand(args) {
|
|
|
1854
1871
|
}
|
|
1855
1872
|
|
|
1856
1873
|
async function doctorCommand(args) {
|
|
1874
|
+
if (args.id) {
|
|
1875
|
+
const report = await doctorGoal(args.root, args.id);
|
|
1876
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1877
|
+
return report.ok ? 0 : 1;
|
|
1878
|
+
}
|
|
1857
1879
|
const report = await doctorReport(args.root, { limit: args.limit ?? 10 });
|
|
1858
1880
|
if (args.json) {
|
|
1859
1881
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -1897,12 +1919,28 @@ async function repairPlanCommand(args) {
|
|
|
1897
1919
|
}
|
|
1898
1920
|
|
|
1899
1921
|
async function initCommand(args) {
|
|
1922
|
+
if (args.id || args.goal) {
|
|
1923
|
+
if (!args.id || !args.goal) throw new Error('Goal init requires both --id and --goal.');
|
|
1924
|
+
console.log(JSON.stringify(await initGoal(args.root, { id: args.id, goal: args.goal }), null, 2));
|
|
1925
|
+
return 0;
|
|
1926
|
+
}
|
|
1900
1927
|
const config = await initWorkspace(args.root, { force: args.force });
|
|
1901
1928
|
console.log(`initialized loop engineering at ${args.root}`);
|
|
1902
1929
|
console.log(`config: ${config}`);
|
|
1903
1930
|
return 0;
|
|
1904
1931
|
}
|
|
1905
1932
|
|
|
1933
|
+
async function reviewCommand(args) {
|
|
1934
|
+
if (!args.id) throw new Error('review requires --id.');
|
|
1935
|
+
const result = await reviewGoal(args.root, args.id, {
|
|
1936
|
+
decision: args.decision,
|
|
1937
|
+
reason: args.reason ?? args.comment ?? '',
|
|
1938
|
+
revision: args.revision ?? 0
|
|
1939
|
+
});
|
|
1940
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1941
|
+
return 0;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1906
1944
|
async function queueInitCommand(args) {
|
|
1907
1945
|
if (!args.queue) throw new Error('queue-init requires --queue.');
|
|
1908
1946
|
const config = await initQueueConfig(args.root, args.queue, { force: args.force });
|
|
@@ -5611,6 +5649,7 @@ async function main() {
|
|
|
5611
5649
|
if (command === 'run') return runCommand(args);
|
|
5612
5650
|
if (command === 'verify') return verifyCommand(args);
|
|
5613
5651
|
if (command === 'status') return statusCommand(args);
|
|
5652
|
+
if (command === 'review') return reviewCommand(args);
|
|
5614
5653
|
if (command === 'summarize') return summarizeCommand(args);
|
|
5615
5654
|
if (command === 'doctor') return doctorCommand(args);
|
|
5616
5655
|
if (command.startsWith('dashboard-')) return dashboardCommand(command, args);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Transactional kernel and Goal API
|
|
2
|
+
|
|
3
|
+
The public API is `Goal.init`, `Goal.run`, `Goal.status`, `Goal.review`, and `Goal.doctor` from `lib/goal-api.mjs`. The matching primary CLI is:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
loop-engineering init --id demo --goal "Ship the complete verified result"
|
|
7
|
+
loop-engineering run --id demo
|
|
8
|
+
loop-engineering status --id demo
|
|
9
|
+
loop-engineering review --id demo --decision revise --reason "change strategy"
|
|
10
|
+
loop-engineering doctor --id demo
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Transaction model
|
|
14
|
+
|
|
15
|
+
Every mutation enters `TransactionalStateKernel.transact` with typed effects. Writers hold an exclusive lease, receive a monotonically increasing fencing token, and may provide `expectedGeneration` for compare-and-swap. Each new effect produces a receipt containing its digest and the previous receipt hash. State records the receipt head and exact effect keys, so replay applies the same logical effect zero or one times.
|
|
16
|
+
|
|
17
|
+
Completion is fenced. Its validator must accept the complete terminal contract; a successful milestone alone cannot set `completed`. Human gates, revisions, action reservations, evidence, and completion are effect types rather than replacement state machines, so existing queue and reservation artifacts remain compatible.
|
|
18
|
+
|
|
19
|
+
External adapters must use the effect key as their upstream idempotency key. For uncertain provider outcomes, reconcile that key before retrying; do not invent a new effect key.
|
|
20
|
+
|
|
21
|
+
## Compatibility and migration
|
|
22
|
+
|
|
23
|
+
All pre-0.16 advanced commands remain available. Existing `run --config`, `status --config`, queue, project, action-reservation, revision, human-gate, dashboard, and code-worktree commands retain their behavior.
|
|
24
|
+
|
|
25
|
+
The five-command surface is additive. Keep existing automation unchanged, create new goals through `init --id --goal`, use `run/status/review/doctor --id` ordinarily, and retain advanced commands for administration and diagnostics. No automatic migration rewrites legacy state. Adapters may project terminal contracts, revision lineage, human gates, and action reservations as typed effects while keeping authoritative legacy artifacts intact.
|
package/lib/core.mjs
CHANGED
|
@@ -2428,8 +2428,8 @@ export async function refreshTaskAcceptance(root, options = {}) {
|
|
|
2428
2428
|
};
|
|
2429
2429
|
}
|
|
2430
2430
|
|
|
2431
|
-
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
2432
|
-
|
|
2431
|
+
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en', blockers = null) {
|
|
2432
|
+
blockers = blockers ?? (Array.isArray(checkpoint.blockers) ? checkpoint.blockers : []);
|
|
2433
2433
|
const deferredGates = materializableDeferredGates(checkpoint);
|
|
2434
2434
|
const requirements = blockers.length > 0 ? blockers : deferredGates;
|
|
2435
2435
|
const blockerText = requirements.length
|
|
@@ -2453,45 +2453,99 @@ function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
|
2453
2453
|
].join('\n');
|
|
2454
2454
|
}
|
|
2455
2455
|
|
|
2456
|
+
function blockerCoveredByContractAuthorization(blocker, contract) {
|
|
2457
|
+
if (!blocker || typeof blocker !== 'object' || Array.isArray(blocker)) return false;
|
|
2458
|
+
const state = String(blocker.authorization_state ?? blocker.state ?? '')
|
|
2459
|
+
.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
|
2460
|
+
const neededWhen = String(blocker.needed_when ?? '')
|
|
2461
|
+
.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
|
2462
|
+
if (['authorized', 'consumed', 'satisfied', 'future', 'not_required'].includes(state)) return true;
|
|
2463
|
+
// A conditional blocker is non-materializable only while the condition is
|
|
2464
|
+
// still in the future. Once the producer explicitly says it is needed now
|
|
2465
|
+
// and materialize=true, it is a real current blocker and must stop the
|
|
2466
|
+
// queue instead of being downgraded to an endless development revision.
|
|
2467
|
+
if (state === 'conditional'
|
|
2468
|
+
&& blocker.materialize !== true
|
|
2469
|
+
&& !['now', 'current', 'immediate'].includes(neededWhen)) return true;
|
|
2470
|
+
if (blocker.materialize === false) return true;
|
|
2471
|
+
const allowed = new Set(contract?.constraints?.allowed_actions ?? []);
|
|
2472
|
+
if (!allowed.has('in_scope_production_deploy_config_backup_restore_rollback_under_standing_authorization')) return false;
|
|
2473
|
+
const text = `${blocker.action ?? ''} ${blocker.required_authority ?? blocker.reason ?? ''}`.toLowerCase();
|
|
2474
|
+
const productionSequence = /(deploy|deployment|materialize|activate|reactivate|restart|process[- ]control|backup|restore|rollback|readiness|persistence|部署|重启|备份|恢复|回滚)/.test(text);
|
|
2475
|
+
const excluded = /(publish|publication|credential|secret|delete|destructive|external send|发布|凭据|密钥|删除|破坏性|外部发送)/.test(text);
|
|
2476
|
+
return productionSequence && !excluded;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function materializableBlockers(checkpoint, contract = null) {
|
|
2480
|
+
const blockers = Array.isArray(checkpoint?.blockers) ? checkpoint.blockers.filter(Boolean) : [];
|
|
2481
|
+
return blockers.filter((blocker) => !blockerCoveredByContractAuthorization(blocker, contract));
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2456
2484
|
function materializableDeferredGates(checkpoint) {
|
|
2457
2485
|
const gates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
|
|
2458
2486
|
return gates.filter((gate) => {
|
|
2459
2487
|
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) return false;
|
|
2460
2488
|
const action = gate.action ?? gate.kind;
|
|
2461
2489
|
const authority = gate.required_authority ?? gate.human_action_required;
|
|
2490
|
+
const authorizationState = String(gate.authorization_state ?? gate.state ?? '')
|
|
2491
|
+
.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
|
2492
|
+
const neededWhen = String(gate.needed_when ?? '')
|
|
2493
|
+
.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
|
2494
|
+
const nonMaterializableStates = new Set([
|
|
2495
|
+
'authorized', 'consumed', 'satisfied', 'future', 'conditional', 'not_required'
|
|
2496
|
+
]);
|
|
2497
|
+
// Deferred gates are future-boundary records, not implicit current human
|
|
2498
|
+
// requests. Require an explicit missing/required state and a current
|
|
2499
|
+
// needed_when marker before materializing one. Legacy unstructured notes
|
|
2500
|
+
// remain audit evidence and cannot manufacture a waiting gate.
|
|
2501
|
+
const explicitlyMissing = ['missing', 'required', 'unauthorized'].includes(authorizationState);
|
|
2502
|
+
const neededNow = ['now', 'current', 'immediate'].includes(neededWhen);
|
|
2462
2503
|
return typeof action === 'string' && action.trim().length > 0
|
|
2463
2504
|
&& typeof authority === 'string' && authority.trim().length > 0
|
|
2464
2505
|
&& gate.materialize !== false
|
|
2465
|
-
&&
|
|
2506
|
+
&& explicitlyMissing
|
|
2507
|
+
&& neededNow
|
|
2508
|
+
&& !nonMaterializableStates.has(authorizationState)
|
|
2509
|
+
&& !['future', 'later', 'conditional', 'after_acceptance', 'after_completion'].includes(neededWhen);
|
|
2466
2510
|
});
|
|
2467
2511
|
}
|
|
2468
2512
|
|
|
2469
|
-
async function projectBacklogItems(root, spec) {
|
|
2513
|
+
async function projectBacklogItems(root, spec, checkpoint = null) {
|
|
2470
2514
|
if (!spec) return [];
|
|
2471
|
-
const
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2515
|
+
const checkpointSource = checkpoint?.backlog_source ?? checkpoint?.authoritative_backlog;
|
|
2516
|
+
const configuredSources = [
|
|
2517
|
+
checkpointSource,
|
|
2518
|
+
...(Array.isArray(spec.checkpointBacklogSources) ? spec.checkpointBacklogSources : []),
|
|
2519
|
+
spec.backlogSource,
|
|
2520
|
+
spec.authoritativeBacklog
|
|
2521
|
+
].filter(Boolean);
|
|
2522
|
+
const items = [];
|
|
2523
|
+
for (const configured of configuredSources) {
|
|
2524
|
+
let loaded;
|
|
2525
|
+
try {
|
|
2526
|
+
loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
|
|
2527
|
+
} catch {
|
|
2528
|
+
continue;
|
|
2529
|
+
}
|
|
2530
|
+
const loadedItems = Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
|
|
2531
|
+
items.push(...loadedItems);
|
|
2478
2532
|
}
|
|
2479
|
-
return
|
|
2533
|
+
return [...new Map(items.filter((item) => item?.id).map((item) => [item.id, item])).values()];
|
|
2480
2534
|
}
|
|
2481
2535
|
|
|
2482
2536
|
async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
|
|
2483
2537
|
const direct = specs.find((item) => item.project === projectId);
|
|
2484
2538
|
const milestoneId = checkpoint?.milestone_id;
|
|
2485
2539
|
if (!milestoneId) return direct;
|
|
2486
|
-
if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
|
|
2540
|
+
if ((await projectBacklogItems(root, direct, checkpoint)).some((item) => item.id === milestoneId)) return direct;
|
|
2487
2541
|
for (const spec of specs) {
|
|
2488
|
-
if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
|
|
2542
|
+
if ((await projectBacklogItems(root, spec, checkpoint)).some((item) => item.id === milestoneId)) return spec;
|
|
2489
2543
|
}
|
|
2490
2544
|
return direct;
|
|
2491
2545
|
}
|
|
2492
2546
|
|
|
2493
|
-
async function projectHasSafeActionableBacklog(root, spec) {
|
|
2494
|
-
const items = await projectBacklogItems(root, spec);
|
|
2547
|
+
async function projectHasSafeActionableBacklog(root, spec, checkpoint = null) {
|
|
2548
|
+
const items = await projectBacklogItems(root, spec, checkpoint);
|
|
2495
2549
|
const byId = new Map(items.map((item) => [item.id, item]));
|
|
2496
2550
|
const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
|
|
2497
2551
|
const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
|
|
@@ -2528,6 +2582,8 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2528
2582
|
for (const [taskId, entry] of tasks) {
|
|
2529
2583
|
if (!entry.task.source || entry.subdir === 'canceled') continue;
|
|
2530
2584
|
const runtimeDir = taskRuntimeDirFor(root, queue, taskId);
|
|
2585
|
+
const contractFile = path.join(runtimeDir, 'task_contract.json');
|
|
2586
|
+
const contract = await exists(contractFile) ? await readJson(contractFile) : null;
|
|
2531
2587
|
let files = await listJson(path.join(runtimeDir, 'checkpoints'));
|
|
2532
2588
|
const judgementFile = path.join(runtimeDir, 'final_judgement.json');
|
|
2533
2589
|
if (await exists(judgementFile)) {
|
|
@@ -2542,8 +2598,9 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2542
2598
|
const checkpointFile = path.join(runtimeDir, 'checkpoints', file);
|
|
2543
2599
|
const checkpoint = await readJson(checkpointFile);
|
|
2544
2600
|
const deferred = materializableDeferredGates(checkpoint);
|
|
2545
|
-
|
|
2546
|
-
|
|
2601
|
+
const blockers = materializableBlockers(checkpoint, contract);
|
|
2602
|
+
if ((['needs_human_input', 'blocked'].includes(checkpoint?.status) && blockers.length > 0) || deferred.length > 0) {
|
|
2603
|
+
checkpoints.push({ file, checkpoint, blockers, mtimeMs: (await stat(checkpointFile)).mtimeMs });
|
|
2547
2604
|
}
|
|
2548
2605
|
}
|
|
2549
2606
|
if (checkpoints.length === 0) continue;
|
|
@@ -2554,7 +2611,7 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2554
2611
|
const latestCheckpoint = checkpoints[0].checkpoint;
|
|
2555
2612
|
const latestDeferredGates = materializableDeferredGates(latestCheckpoint);
|
|
2556
2613
|
const deferredOnly = latestDeferredGates.length > 0
|
|
2557
|
-
&&
|
|
2614
|
+
&& checkpoints[0].blockers.length === 0
|
|
2558
2615
|
&& !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
|
|
2559
2616
|
const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
|
|
2560
2617
|
if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
|
|
@@ -2562,8 +2619,8 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2562
2619
|
// waiting gate only after the authoritative backlog has no unrelated safe
|
|
2563
2620
|
// item left to run. Otherwise a future paid/deploy/publish boundary would
|
|
2564
2621
|
// incorrectly stop local project development.
|
|
2565
|
-
if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
|
|
2566
|
-
candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
|
|
2622
|
+
if (deferredOnly && await projectHasSafeActionableBacklog(root, spec, latestCheckpoint)) continue;
|
|
2623
|
+
candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, contract, ...checkpoints[0] });
|
|
2567
2624
|
}
|
|
2568
2625
|
|
|
2569
2626
|
// A project deferred gate is materialized only from its newest authoritative
|
|
@@ -2598,7 +2655,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2598
2655
|
await mkdir(gatesDir, { recursive: true });
|
|
2599
2656
|
const results = [];
|
|
2600
2657
|
const candidates = await authoritativeHumanGateCandidates(root, queue, tasks);
|
|
2601
|
-
for (const { taskId, entry, file, checkpoint } of candidates) {
|
|
2658
|
+
for (const { taskId, entry, file, checkpoint, blockers } of candidates) {
|
|
2602
2659
|
const deferredGates = materializableDeferredGates(checkpoint);
|
|
2603
2660
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
2604
2661
|
const gateId = `${taskId}:${checkpointId}`;
|
|
@@ -2654,7 +2711,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2654
2711
|
}
|
|
2655
2712
|
}
|
|
2656
2713
|
}
|
|
2657
|
-
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
|
|
2714
|
+
const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language, blockers);
|
|
2658
2715
|
if (options.dryRun) {
|
|
2659
2716
|
results.push({ taskId, checkpointId, gateId, outcome: 'dry_run', message, source: entry.task.source });
|
|
2660
2717
|
continue;
|
|
@@ -3445,6 +3502,14 @@ function buildDevPlan(contract, acceptancePlan, firstCheckpointId = 'cp1') {
|
|
|
3445
3502
|
files_changed: [],
|
|
3446
3503
|
verification: [],
|
|
3447
3504
|
blockers: [],
|
|
3505
|
+
blocker_schema: {
|
|
3506
|
+
action: 'Concrete action that cannot proceed now.',
|
|
3507
|
+
required_authority: 'Specific authority or input that is currently missing.',
|
|
3508
|
+
authorization_state: 'missing | authorized | consumed | satisfied | future | conditional | not_required',
|
|
3509
|
+
authority_ref: 'Required when authorization_state is not missing.',
|
|
3510
|
+
needed_when: 'now | future condition',
|
|
3511
|
+
materialize: 'Set false for authorized or non-current boundaries.'
|
|
3512
|
+
},
|
|
3448
3513
|
deferred_gates: [],
|
|
3449
3514
|
deferred_gate_schema: {
|
|
3450
3515
|
action: 'Concrete action that cannot proceed now.',
|
|
@@ -3494,10 +3559,11 @@ async function checkpointSummary(root, devPlan) {
|
|
|
3494
3559
|
};
|
|
3495
3560
|
}
|
|
3496
3561
|
|
|
3497
|
-
function checkpointReviewStatus(checkpoint) {
|
|
3562
|
+
function checkpointReviewStatus(checkpoint, contract = null) {
|
|
3498
3563
|
if (!checkpoint) return 'blocked';
|
|
3499
|
-
|
|
3500
|
-
if (
|
|
3564
|
+
const blockers = materializableBlockers(checkpoint, contract);
|
|
3565
|
+
if ((checkpoint.status === 'blocked' || checkpoint.status === 'needs_human_input') && blockers.length > 0) return 'blocked';
|
|
3566
|
+
if (blockers.length > 0) return 'revise';
|
|
3501
3567
|
if (!Array.isArray(checkpoint.verification) || checkpoint.verification.length === 0) return 'revise';
|
|
3502
3568
|
if (checkpoint.status !== 'ready_for_acceptance') return 'revise';
|
|
3503
3569
|
return 'accepted';
|
|
@@ -3605,7 +3671,7 @@ function evaluateAcceptanceCritic(critic, context) {
|
|
|
3605
3671
|
function buildCriticReviews(contract, acceptancePlan, checkpoint) {
|
|
3606
3672
|
const missingCheckpoint = !checkpoint;
|
|
3607
3673
|
const hasVerification = Array.isArray(checkpoint?.verification) && checkpoint.verification.length > 0;
|
|
3608
|
-
const hasBlockers =
|
|
3674
|
+
const hasBlockers = materializableBlockers(checkpoint, contract).length > 0;
|
|
3609
3675
|
const hasSummary = typeof checkpoint?.summary === 'string' && checkpoint.summary.trim().length > 0;
|
|
3610
3676
|
const hasRisks = Array.isArray(checkpoint?.risks);
|
|
3611
3677
|
const blockedActions = contract.constraints?.blocked_actions ?? [];
|
|
@@ -3652,7 +3718,7 @@ function continuationNextAction(value) {
|
|
|
3652
3718
|
}
|
|
3653
3719
|
|
|
3654
3720
|
function buildCheckpointReview(contract, acceptancePlan, checkpoint) {
|
|
3655
|
-
const baseStatus = checkpointReviewStatus(checkpoint);
|
|
3721
|
+
const baseStatus = checkpointReviewStatus(checkpoint, contract);
|
|
3656
3722
|
const failed = [];
|
|
3657
3723
|
const passed = [];
|
|
3658
3724
|
const blocked = [];
|
|
@@ -3677,8 +3743,9 @@ function buildCheckpointReview(contract, acceptancePlan, checkpoint) {
|
|
|
3677
3743
|
});
|
|
3678
3744
|
}
|
|
3679
3745
|
|
|
3680
|
-
|
|
3681
|
-
|
|
3746
|
+
const effectiveBlockers = materializableBlockers(checkpoint, contract);
|
|
3747
|
+
if (effectiveBlockers.length > 0) {
|
|
3748
|
+
blocked.push(...effectiveBlockers.map((item) => typeof item === 'string' ? item : JSON.stringify(item)));
|
|
3682
3749
|
} else {
|
|
3683
3750
|
passed.push('Checkpoint reports no blockers.');
|
|
3684
3751
|
}
|
|
@@ -9348,6 +9415,9 @@ export async function runQueueOnce(root, options) {
|
|
|
9348
9415
|
env: {
|
|
9349
9416
|
...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
|
|
9350
9417
|
LOOP_SESSION_GENERATION: String(task.runtimeSessionGeneration ?? 0),
|
|
9418
|
+
LOOP_EXECUTION_TARGET_JSON: options.executionTarget
|
|
9419
|
+
? JSON.stringify(options.executionTarget)
|
|
9420
|
+
: '',
|
|
9351
9421
|
LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
|
|
9352
9422
|
LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
|
|
9353
9423
|
},
|
package/lib/goal-api.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { TransactionalStateKernel } from './transactional-state-kernel.mjs';
|
|
4
|
+
|
|
5
|
+
const goalDir = (root, id) => path.join(root, 'runtime', 'loops', 'goals', id);
|
|
6
|
+
const requireId = (id) => {
|
|
7
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id ?? '')) throw new Error('Goal id is invalid.');
|
|
8
|
+
return id;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function initGoal(root, input) {
|
|
12
|
+
const id = requireId(input.id);
|
|
13
|
+
if (typeof input.goal !== 'string' || input.goal.trim().length < 8) throw new Error('Goal must be a meaningful string.');
|
|
14
|
+
const directory = goalDir(root, id); await mkdir(directory, { recursive: true });
|
|
15
|
+
const contract = { version: 1, id, goal: input.goal, terminal_contract: input.terminalContract ?? null, created_at: new Date().toISOString() };
|
|
16
|
+
const file = path.join(directory, 'goal.json');
|
|
17
|
+
await writeFile(file, `${JSON.stringify(contract, null, 2)}\n`, { flag: 'wx' }).catch((error) => { if (error.code !== 'EEXIST') throw error; });
|
|
18
|
+
return statusGoal(root, id);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function statusGoal(root, id) {
|
|
22
|
+
requireId(id); const directory = goalDir(root, id);
|
|
23
|
+
const contract = JSON.parse(await readFile(path.join(directory, 'goal.json'), 'utf8'));
|
|
24
|
+
const kernel = new TransactionalStateKernel(path.join(directory, 'kernel'));
|
|
25
|
+
return { contract, runtime: await kernel.inspect(), receipt_chain: await kernel.verifyReceiptChain() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runGoal(root, id, input = {}) {
|
|
29
|
+
requireId(id); const directory = goalDir(root, id);
|
|
30
|
+
await readFile(path.join(directory, 'goal.json'), 'utf8');
|
|
31
|
+
const kernel = new TransactionalStateKernel(path.join(directory, 'kernel'));
|
|
32
|
+
return kernel.transact({
|
|
33
|
+
expectedGeneration: input.expectedGeneration,
|
|
34
|
+
effects: input.effects ?? [{ type: 'state_transition', key: `run:${input.triggerId ?? 'manual'}`, payload: { trigger: input.triggerId ?? 'manual' } }],
|
|
35
|
+
status: input.status ?? 'running',
|
|
36
|
+
reduce: input.reduce ?? ((state, effects) => ({ ...state, last_effects: effects.map((item) => item.key) })),
|
|
37
|
+
complete: input.complete
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function reviewGoal(root, id, review) {
|
|
42
|
+
if (!['accept', 'revise', 'wait'].includes(review.decision)) throw new Error('Review decision must be accept, revise, or wait.');
|
|
43
|
+
return runGoal(root, id, {
|
|
44
|
+
expectedGeneration: review.expectedGeneration,
|
|
45
|
+
effects: [{
|
|
46
|
+
type: review.decision === 'wait' ? 'human_gate' : review.decision === 'revise' ? 'revision' : 'evidence',
|
|
47
|
+
key: review.key ?? `review:${review.decision}:${review.revision ?? 0}`,
|
|
48
|
+
payload: review
|
|
49
|
+
}],
|
|
50
|
+
status: review.decision === 'wait' ? 'waiting_for_human' : review.decision === 'revise' ? 'revision_pending' : 'accepted'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function doctorGoal(root, id) {
|
|
55
|
+
try { const status = await statusGoal(root, id); return { ok: true, id, generation: status.runtime.generation, receipt_chain: status.receipt_chain }; }
|
|
56
|
+
catch (error) { return { ok: false, id, error: error.message }; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const Goal = Object.freeze({ init: initGoal, run: runGoal, status: statusGoal, review: reviewGoal, doctor: doctorGoal });
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const EFFECT_TYPES = Object.freeze([
|
|
6
|
+
'state_transition', 'human_gate', 'revision', 'action_reservation',
|
|
7
|
+
'external_action', 'evidence', 'completion'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const allowedTypes = new Set(EFFECT_TYPES);
|
|
11
|
+
const canonical = (value) => JSON.stringify(sort(value));
|
|
12
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
function sort(value) {
|
|
14
|
+
if (Array.isArray(value)) return value.map(sort);
|
|
15
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sort(value[key])]));
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readJson(file, fallback = null) {
|
|
20
|
+
try { return JSON.parse(await readFile(file, 'utf8')); }
|
|
21
|
+
catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function atomicWrite(file, value) {
|
|
25
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
26
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
27
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
28
|
+
await rename(temporary, file);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function typedEffect(type, payload, options = {}) {
|
|
32
|
+
if (!allowedTypes.has(type)) throw new Error(`Unsupported effect type: ${type}`);
|
|
33
|
+
if (payload === undefined) throw new Error('Effect payload is required.');
|
|
34
|
+
const effect = {
|
|
35
|
+
version: 1,
|
|
36
|
+
type,
|
|
37
|
+
key: options.key ?? hash(canonical({ type, payload })),
|
|
38
|
+
payload: sort(payload)
|
|
39
|
+
};
|
|
40
|
+
effect.digest = hash(canonical(effect));
|
|
41
|
+
return Object.freeze(effect);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class TransactionalStateKernel {
|
|
45
|
+
constructor(directory) {
|
|
46
|
+
this.directory = directory;
|
|
47
|
+
this.stateFile = path.join(directory, 'state.json');
|
|
48
|
+
this.receiptFile = path.join(directory, 'receipts.jsonl');
|
|
49
|
+
this.lockFile = path.join(directory, 'writer.lock');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async inspect() {
|
|
53
|
+
return await readJson(this.stateFile, {
|
|
54
|
+
version: 1, generation: 0, fencing_token: 0, status: 'initialized',
|
|
55
|
+
state: {}, applied_effects: {}, receipts: [], last_receipt: null, completion: null
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async acquire(owner = `pid:${process.pid}`) {
|
|
60
|
+
await mkdir(this.directory, { recursive: true });
|
|
61
|
+
let handle;
|
|
62
|
+
try { handle = await open(this.lockFile, 'wx'); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 'EEXIST') throw error;
|
|
65
|
+
const stale = await readJson(this.lockFile, null);
|
|
66
|
+
const pid = Number(String(stale?.owner ?? '').match(/^pid:(\d+)$/)?.[1]);
|
|
67
|
+
let alive = Number.isInteger(pid) && pid > 0;
|
|
68
|
+
if (alive) { try { process.kill(pid, 0); } catch (probe) { if (probe.code === 'ESRCH') alive = false; else throw probe; } }
|
|
69
|
+
if (alive || !pid) throw new Error('Transactional writer lease is active or has an unverifiable owner.');
|
|
70
|
+
await rm(this.lockFile, { force: true });
|
|
71
|
+
handle = await open(this.lockFile, 'wx');
|
|
72
|
+
}
|
|
73
|
+
const current = await this.inspect();
|
|
74
|
+
const lease = { owner, fencingToken: current.fencing_token + 1, generation: current.generation, handle };
|
|
75
|
+
await handle.writeFile(JSON.stringify({ owner, fencing_token: lease.fencingToken }));
|
|
76
|
+
return lease;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async release(lease) {
|
|
80
|
+
await lease.handle.close();
|
|
81
|
+
await rm(this.lockFile, { force: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async transact(input) {
|
|
85
|
+
const lease = input.lease ?? await this.acquire(input.owner);
|
|
86
|
+
const owned = !input.lease;
|
|
87
|
+
try {
|
|
88
|
+
const current = await this.inspect();
|
|
89
|
+
if (lease.fencingToken <= current.fencing_token) throw new Error('Stale fencing token.');
|
|
90
|
+
if (input.expectedGeneration !== undefined && input.expectedGeneration !== current.generation) {
|
|
91
|
+
throw new Error(`CAS generation mismatch: expected ${input.expectedGeneration}, actual ${current.generation}.`);
|
|
92
|
+
}
|
|
93
|
+
const effects = (input.effects ?? []).map((effect) => typedEffect(effect.type, effect.payload, { key: effect.key }));
|
|
94
|
+
const fresh = effects.filter((effect) => !current.applied_effects[effect.key]);
|
|
95
|
+
const nextState = input.reduce ? await input.reduce(structuredClone(current.state), fresh) : current.state;
|
|
96
|
+
const next = {
|
|
97
|
+
...current,
|
|
98
|
+
generation: current.generation + 1,
|
|
99
|
+
fencing_token: lease.fencingToken,
|
|
100
|
+
status: input.status ?? current.status,
|
|
101
|
+
state: nextState,
|
|
102
|
+
applied_effects: { ...current.applied_effects },
|
|
103
|
+
updated_at: new Date().toISOString()
|
|
104
|
+
};
|
|
105
|
+
const receipts = [];
|
|
106
|
+
let previous = current.last_receipt;
|
|
107
|
+
for (const effect of fresh) {
|
|
108
|
+
const receipt = {
|
|
109
|
+
version: 1, transaction_id: input.transactionId ?? randomUUID(),
|
|
110
|
+
generation: next.generation, fencing_token: lease.fencingToken,
|
|
111
|
+
effect_key: effect.key, effect_digest: effect.digest, effect_type: effect.type,
|
|
112
|
+
previous, created_at: new Date().toISOString()
|
|
113
|
+
};
|
|
114
|
+
receipt.receipt = hash(canonical(receipt));
|
|
115
|
+
previous = receipt.receipt;
|
|
116
|
+
next.applied_effects[effect.key] = { receipt: receipt.receipt, generation: next.generation, effect };
|
|
117
|
+
receipts.push(receipt);
|
|
118
|
+
}
|
|
119
|
+
next.last_receipt = previous;
|
|
120
|
+
next.receipts = [...(current.receipts ?? []), ...receipts];
|
|
121
|
+
if (input.complete) {
|
|
122
|
+
const verdict = await input.complete.validate({ current, next, freshEffects: fresh });
|
|
123
|
+
if (!verdict?.ok) throw new Error(`Completion fence rejected: ${verdict?.reason ?? 'validation failed'}`);
|
|
124
|
+
next.status = 'completed';
|
|
125
|
+
next.completion = { fenced_at_generation: next.generation, evidence: verdict.evidence ?? [], terminal_contract: input.complete.terminalContract ?? null };
|
|
126
|
+
}
|
|
127
|
+
await atomicWrite(this.stateFile, next);
|
|
128
|
+
if (receipts.length) await writeFile(this.receiptFile, receipts.map((item) => JSON.stringify(item)).join('\n') + '\n', { flag: 'a' });
|
|
129
|
+
return { state: next, receipts, replayed: effects.length - fresh.length };
|
|
130
|
+
} finally { if (owned) await this.release(lease); }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async replayEffect(effect, execute) {
|
|
134
|
+
const normalized = typedEffect(effect.type, effect.payload, { key: effect.key });
|
|
135
|
+
const current = await this.inspect();
|
|
136
|
+
const existing = current.applied_effects[normalized.key];
|
|
137
|
+
if (existing) return { executed: false, replayed: true, receipt: existing.receipt };
|
|
138
|
+
const outcome = await execute(normalized);
|
|
139
|
+
const committed = await this.transact({
|
|
140
|
+
expectedGeneration: current.generation,
|
|
141
|
+
effects: [normalized],
|
|
142
|
+
reduce: (state) => ({ ...state, effect_outcomes: { ...(state.effect_outcomes ?? {}), [normalized.key]: outcome } })
|
|
143
|
+
});
|
|
144
|
+
return { executed: true, replayed: false, outcome, receipt: committed.receipts[0]?.receipt };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async verifyReceiptChain() {
|
|
148
|
+
const state = await this.inspect();
|
|
149
|
+
let previous = null; let count = 0;
|
|
150
|
+
for (const item of state.receipts ?? []) {
|
|
151
|
+
const claimed = item.receipt; const unsigned = { ...item }; delete unsigned.receipt;
|
|
152
|
+
if (item.previous !== previous || hash(canonical(unsigned)) !== claimed) throw new Error(`Receipt chain invalid at ${count + 1}.`);
|
|
153
|
+
previous = claimed; count++;
|
|
154
|
+
}
|
|
155
|
+
if (state.last_receipt !== previous) throw new Error('Receipt head does not match state.');
|
|
156
|
+
return { ok: true, count, head: previous };
|
|
157
|
+
}
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"run-loop-cron.sh": "scripts/run-loop-cron.sh"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
+
"test": "npm run check && npm run check:competitive",
|
|
21
|
+
"check:competitive": "node --check lib/transactional-state-kernel.mjs && node --check lib/goal-api.mjs && node scripts/competitive-acceptance.mjs",
|
|
20
22
|
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
|
|
21
23
|
"demo:adapter": "node examples/adapter-sdk-demo.mjs",
|
|
22
24
|
"check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/execution-ledger.mjs && node --check lib/production-evidence.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
|
|
@@ -27,6 +29,12 @@
|
|
|
27
29
|
"check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node --check lib/action-reservations.mjs && node --check lib/todo-control-plane.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/operator-dashboard-self-test.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node scripts/human-gate-lifecycle-v2-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs dashboard-health --root . --max-age-seconds 999999999 --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
|
|
28
30
|
"pack:dry": "npm pack --dry-run"
|
|
29
31
|
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./lib/goal-api.mjs",
|
|
34
|
+
"./goal": "./lib/goal-api.mjs",
|
|
35
|
+
"./transactional-kernel": "./lib/transactional-state-kernel.mjs",
|
|
36
|
+
"./runtime-adapter-sdk": "./lib/runtime-adapter-sdk.mjs"
|
|
37
|
+
},
|
|
30
38
|
"engines": {
|
|
31
39
|
"node": ">=22"
|
|
32
40
|
},
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Goal, initGoal, reviewGoal, runGoal, statusGoal } from '../lib/goal-api.mjs';
|
|
6
|
+
import { TransactionalStateKernel } from '../lib/transactional-state-kernel.mjs';
|
|
7
|
+
|
|
8
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-competitive-'));
|
|
9
|
+
const results = [];
|
|
10
|
+
const fixture = async (id, run) => { try { await run(); results.push({ id, ok: true }); } catch (error) { results.push({ id, ok: false, error: error.message }); } };
|
|
11
|
+
|
|
12
|
+
await fixture('crash_recovery', async () => {
|
|
13
|
+
await initGoal(root, { id: 'crash', goal: 'Recover durable progress after process interruption.' });
|
|
14
|
+
await runGoal(root, 'crash', { triggerId: 'before-crash' });
|
|
15
|
+
const kernelDir = path.join(root, 'runtime', 'loops', 'goals', 'crash', 'kernel');
|
|
16
|
+
await rm(path.join(kernelDir, 'receipts.jsonl'), { force: true });
|
|
17
|
+
await mkdir(kernelDir, { recursive: true });
|
|
18
|
+
await writeFile(path.join(kernelDir, 'writer.lock'), JSON.stringify({ owner: 'pid:2147483647' }));
|
|
19
|
+
await runGoal(root, 'crash', { triggerId: 'after-crash' });
|
|
20
|
+
const reopened = await statusGoal(root, 'crash');
|
|
21
|
+
assert.equal(reopened.runtime.generation, 2); assert.equal(reopened.receipt_chain.ok, true);
|
|
22
|
+
});
|
|
23
|
+
await fixture('duplicate_trigger', async () => {
|
|
24
|
+
await initGoal(root, { id: 'duplicate', goal: 'Apply each duplicate trigger at most once.' });
|
|
25
|
+
await runGoal(root, 'duplicate', { triggerId: 'same' });
|
|
26
|
+
const duplicate = await runGoal(root, 'duplicate', { triggerId: 'same' });
|
|
27
|
+
assert.equal(duplicate.replayed, 1); assert.equal(duplicate.receipts.length, 0);
|
|
28
|
+
});
|
|
29
|
+
await fixture('human_wait_resume', async () => {
|
|
30
|
+
await initGoal(root, { id: 'human', goal: 'Wait for and resume from a human decision.' });
|
|
31
|
+
await reviewGoal(root, 'human', { decision: 'wait', key: 'gate:1', reason: 'choose' });
|
|
32
|
+
assert.equal((await statusGoal(root, 'human')).runtime.status, 'waiting_for_human');
|
|
33
|
+
await reviewGoal(root, 'human', { decision: 'accept', key: 'gate:1:resume' });
|
|
34
|
+
assert.equal((await statusGoal(root, 'human')).runtime.status, 'accepted');
|
|
35
|
+
});
|
|
36
|
+
await fixture('standing_authorization', async () => {
|
|
37
|
+
await initGoal(root, { id: 'standing', goal: 'Respect bounded standing authorization scopes.' });
|
|
38
|
+
const result = await runGoal(root, 'standing', { effects: [{ type: 'action_reservation', key: 'auth:deploy:1', payload: { authorization: { kind: 'standing', scope: 'staging', limit: 1 }, action: 'deploy' } }] });
|
|
39
|
+
assert.equal(result.receipts[0].effect_type, 'action_reservation');
|
|
40
|
+
assert.equal(result.state.applied_effects['auth:deploy:1'].effect.payload.authorization.scope, 'staging');
|
|
41
|
+
});
|
|
42
|
+
await fixture('idempotent_external_action', async () => {
|
|
43
|
+
const kernel = new TransactionalStateKernel(path.join(root, 'external-kernel')); let calls = 0;
|
|
44
|
+
const effect = { type: 'external_action', key: 'provider:request-1', payload: { idempotency_key: 'request-1' } };
|
|
45
|
+
const execute = ({ key }) => { calls++; return { accepted: true, upstream_key: key }; };
|
|
46
|
+
await kernel.replayEffect(effect, execute); const replay = await kernel.replayEffect(effect, execute);
|
|
47
|
+
assert.equal(calls, 1); assert.equal(replay.replayed, true);
|
|
48
|
+
});
|
|
49
|
+
await fixture('false_milestone_completion', async () => {
|
|
50
|
+
const kernel = new TransactionalStateKernel(path.join(root, 'completion-kernel'));
|
|
51
|
+
await assert.rejects(kernel.transact({ effects: [{ type: 'completion', key: 'milestone:1', payload: { milestone: true } }], complete: { terminalContract: { required: ['m1', 'm2'] }, validate: async () => ({ ok: false, reason: 'required backlog remains' }) } }), /Completion fence rejected/);
|
|
52
|
+
assert.notEqual((await kernel.inspect()).status, 'completed');
|
|
53
|
+
});
|
|
54
|
+
await fixture('repeated_revision', async () => {
|
|
55
|
+
await initGoal(root, { id: 'revision', goal: 'Preserve repeated revision lineage without collision.' });
|
|
56
|
+
await reviewGoal(root, 'revision', { decision: 'revise', revision: 1, key: 'revision:1', parent: null });
|
|
57
|
+
await reviewGoal(root, 'revision', { decision: 'revise', revision: 2, key: 'revision:2', parent: 'revision:1' });
|
|
58
|
+
const status = await statusGoal(root, 'revision');
|
|
59
|
+
assert.equal(status.runtime.applied_effects['revision:2'].effect.payload.parent, 'revision:1'); assert.equal(status.receipt_chain.count, 2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(Object.keys(Goal), ['init', 'run', 'status', 'review', 'doctor']);
|
|
63
|
+
await rm(root, { recursive: true, force: true });
|
|
64
|
+
console.log(JSON.stringify({ ok: results.every((item) => item.ok), fixtures: results }, null, 2));
|
|
65
|
+
if (results.some((item) => !item.ok)) process.exitCode = 1;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const root = path.resolve(import.meta.dirname, '..');
|
|
6
|
+
const checks = [];
|
|
7
|
+
const record = (id, ok, evidence) => checks.push({ id, ok, evidence });
|
|
8
|
+
const requiredFiles = ['lib/transactional-state-kernel.mjs', 'lib/goal-api.mjs', '.github/workflows/ci.yml', 'scripts/competitive-acceptance.mjs', 'docs/transactional-kernel-and-goal-api.md'];
|
|
9
|
+
for (const file of requiredFiles) {
|
|
10
|
+
try { await access(path.join(root, file)); record(`file:${file}`, true, file); }
|
|
11
|
+
catch { record(`file:${file}`, false, 'missing'); }
|
|
12
|
+
}
|
|
13
|
+
const source = await readFile(path.join(root, 'lib/transactional-state-kernel.mjs'), 'utf8');
|
|
14
|
+
for (const token of ['state_transition', 'human_gate', 'revision', 'action_reservation', 'external_action', 'completion', 'fencingToken', 'expectedGeneration', 'verifyReceiptChain', 'replayEffect']) {
|
|
15
|
+
record(`kernel:${token}`, source.includes(token), token);
|
|
16
|
+
}
|
|
17
|
+
const cli = await readFile(path.join(root, 'bin/loop-engineering.mjs'), 'utf8');
|
|
18
|
+
for (const command of ['init', 'run', 'status', 'review', 'doctor']) record(`cli:${command}`, cli.includes(`command === '${command}'`), command);
|
|
19
|
+
const fixture = await new Promise((resolve) => {
|
|
20
|
+
const child = spawn(process.execPath, ['scripts/competitive-acceptance.mjs'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
22
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
23
|
+
});
|
|
24
|
+
record('competitive_fixtures', fixture.code === 0, fixture.code === 0 ? '7/7 passed' : fixture.stderr);
|
|
25
|
+
const outcome = checks.every((item) => item.ok) ? 'accept' : 'reject';
|
|
26
|
+
console.log(JSON.stringify({ version: 1, scope: 'complete_project_terminal_contract', independent_from_runtime_implementation: true, outcome, checks, residual_risks: ['Filesystem durability depends on the host filesystem honoring atomic rename and fsync semantics.', 'External exactly-once behavior requires providers to honor the supplied idempotency key.'] }, null, 2));
|
|
27
|
+
if (outcome !== 'accept') process.exitCode = 1;
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { mkdtemp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, routeLoopMessage, taskRuntimeDirFor, writeTaskContract } from '../lib/core.mjs';
|
|
5
|
+
import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, resolveHumanInput, routeLoopMessage, taskRuntimeDirFor, writeTaskContract } from '../lib/core.mjs';
|
|
6
6
|
|
|
7
7
|
const root = await mkdtemp(path.join(os.tmpdir(), 'loop-project-gates-'));
|
|
8
8
|
const queue = 'shared';
|
|
@@ -139,7 +139,11 @@ await writeFile(path.join(deferredDir, 'cp-ready.json'), `${JSON.stringify({
|
|
|
139
139
|
version: 1, task_id: deferred.task.id, checkpoint_id: 'cp-ready', milestone_id: 'S-01', requirement_ids: ['S-01'],
|
|
140
140
|
status: 'ready_for_acceptance', blockers: [], verification: ['local phase passed'], risks: [],
|
|
141
141
|
project_completion: { status: 'in_progress' },
|
|
142
|
-
deferred_gates: [{
|
|
142
|
+
deferred_gates: [{
|
|
143
|
+
id: 'S-01-production', action: 'production_rollback_drill',
|
|
144
|
+
required_authority: 'Owner authorization for the exact production rollback drill scope.',
|
|
145
|
+
authorization_state: 'missing', needed_when: 'now', materialize: true
|
|
146
|
+
}]
|
|
143
147
|
}, null, 2)}\n`);
|
|
144
148
|
const deferredNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
145
149
|
const deferredResult = deferredNotice.results.find((item) => item.taskId === deferred.task.id);
|
|
@@ -187,6 +191,123 @@ await writeFile(path.join(conditionalDir, 'cp1.json'), `${JSON.stringify({
|
|
|
187
191
|
const conditionalNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
188
192
|
assert.equal(conditionalNotice.results.some((item) => item.taskId === conditional.task.id), false);
|
|
189
193
|
|
|
194
|
+
// A conditional formal blocker becomes current when the producer explicitly
|
|
195
|
+
// marks it needed now and materialize=true. It must stop once, rather than be
|
|
196
|
+
// filtered into a needs_revision/project_in_progress polling loop.
|
|
197
|
+
const conditionalNow = await enqueueTask(root, { queue, title: 'OpenReel conditional blocker now', task: 'Wait for a real external precondition', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
198
|
+
const conditionalNowDir = path.join(taskRuntimeDirFor(root, queue, conditionalNow.task.id), 'checkpoints');
|
|
199
|
+
await mkdir(conditionalNowDir, { recursive: true });
|
|
200
|
+
await writeFile(path.join(conditionalNowDir, 'cp1.json'), `${JSON.stringify({
|
|
201
|
+
version: 1, task_id: conditionalNow.task.id, checkpoint_id: 'cp1', status: 'blocked',
|
|
202
|
+
blockers: [{
|
|
203
|
+
action: 'Run the authorized provider probe.', required_authority: 'Restore the required remote execution precondition.',
|
|
204
|
+
authorization_state: 'conditional', needed_when: 'now', materialize: true
|
|
205
|
+
}],
|
|
206
|
+
deferred_gates: []
|
|
207
|
+
}, null, 2)}\n`);
|
|
208
|
+
const conditionalNowNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
209
|
+
assert.equal(conditionalNowNotice.results.find((item) => item.taskId === conditionalNow.task.id)?.outcome, 'sent');
|
|
210
|
+
await resolveHumanInput(root, { queue, gateId: `${conditionalNow.task.id}:cp1`, input: 'external precondition restored' });
|
|
211
|
+
await reconcileProjectGates(root, { queue });
|
|
212
|
+
|
|
213
|
+
// Authorization already granted or already consumed is audit context, not a
|
|
214
|
+
// new human-input request. A future boundary is likewise dormant.
|
|
215
|
+
for (const authorizationState of ['authorized', 'consumed', 'future']) {
|
|
216
|
+
const stateTask = await enqueueTask(root, { queue, title: `OpenReel ${authorizationState} authority`, task: 'Continue within recorded authority', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
217
|
+
const stateDir = path.join(taskRuntimeDirFor(root, queue, stateTask.task.id), 'checkpoints');
|
|
218
|
+
await mkdir(stateDir, { recursive: true });
|
|
219
|
+
await writeFile(path.join(stateDir, 'cp1.json'), `${JSON.stringify({
|
|
220
|
+
version: 1, task_id: stateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
|
|
221
|
+
project_completion: { status: 'in_progress' },
|
|
222
|
+
deferred_gates: [{
|
|
223
|
+
action: 'Execute the bounded action.', required_authority: 'Recorded owner authority.',
|
|
224
|
+
authorization_state: authorizationState, authority_ref: 'test-authority',
|
|
225
|
+
needed_when: authorizationState === 'future' ? 'after_acceptance' : 'now'
|
|
226
|
+
}]
|
|
227
|
+
}, null, 2)}\n`);
|
|
228
|
+
const stateNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
229
|
+
assert.equal(stateNotice.results.some((item) => item.taskId === stateTask.task.id), false, `${authorizationState} authority must not create a waiting gate`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A checkpoint may bind an active subproject backlog. This prevents a global
|
|
233
|
+
// project ledger from hiding the safe next milestone.
|
|
234
|
+
await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'GLOBAL-01', status: 'accepted', dependsOn: [] }] }, null, 2)}\n`);
|
|
235
|
+
const subprojectBacklog = path.join(root, 'project', 'cdqi2-backlog.json');
|
|
236
|
+
await writeFile(subprojectBacklog, `${JSON.stringify({
|
|
237
|
+
status: 'ongoing', items: [
|
|
238
|
+
{ id: 'CDQI2-10', status: 'accepted', dependsOn: [] },
|
|
239
|
+
{ id: 'CDQI2-11', status: 'in_progress', dependsOn: ['CDQI2-10'] }
|
|
240
|
+
]
|
|
241
|
+
}, null, 2)}\n`);
|
|
242
|
+
const subprojectTask = await enqueueTask(root, { queue, title: 'OpenReel CDQI2 actionable backlog', task: 'Continue CDQI2-11', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
243
|
+
const subprojectDir = path.join(taskRuntimeDirFor(root, queue, subprojectTask.task.id), 'checkpoints');
|
|
244
|
+
await mkdir(subprojectDir, { recursive: true });
|
|
245
|
+
await writeFile(path.join(subprojectDir, 'cp1.json'), `${JSON.stringify({
|
|
246
|
+
version: 1, task_id: subprojectTask.task.id, checkpoint_id: 'cp1', milestone_id: 'CDQI2-11',
|
|
247
|
+
backlog_source: 'project/cdqi2-backlog.json', status: 'ready_for_acceptance', blockers: [],
|
|
248
|
+
project_completion: { status: 'in_progress' },
|
|
249
|
+
deferred_gates: [{ action: 'Publish after T11.', required_authority: 'Owner publication approval.' }]
|
|
250
|
+
}, null, 2)}\n`);
|
|
251
|
+
const subprojectNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
252
|
+
assert.equal(subprojectNotice.results.some((item) => item.taskId === subprojectTask.task.id), false, 'safe checkpoint-bound backlog must prevent waiting');
|
|
253
|
+
|
|
254
|
+
// A genuinely missing current authorization becomes a waiting gate once no
|
|
255
|
+
// safe project work remains.
|
|
256
|
+
await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'GLOBAL-01', status: 'accepted', dependsOn: [] }] }, null, 2)}\n`);
|
|
257
|
+
const missing = await enqueueTask(root, { queue, title: 'OpenReel missing current authority', task: 'Perform currently gated action', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
258
|
+
const missingDir = path.join(taskRuntimeDirFor(root, queue, missing.task.id), 'checkpoints');
|
|
259
|
+
await mkdir(missingDir, { recursive: true });
|
|
260
|
+
await writeFile(path.join(missingDir, 'cp1.json'), `${JSON.stringify({
|
|
261
|
+
version: 1, task_id: missing.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
|
|
262
|
+
project_completion: { status: 'in_progress' },
|
|
263
|
+
deferred_gates: [{ action: 'Publish now.', required_authority: 'Owner publication approval.', authorization_state: 'missing', needed_when: 'now' }]
|
|
264
|
+
}, null, 2)}\n`);
|
|
265
|
+
const missingNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
266
|
+
assert.equal(missingNotice.results.find((item) => item.taskId === missing.task.id)?.outcome, 'sent');
|
|
267
|
+
await reconcileProjectGates(root, { queue });
|
|
268
|
+
|
|
269
|
+
// A formal blocker that merely restates an in-scope production sequence
|
|
270
|
+
// covered by the project standing authorization must not stop the queue.
|
|
271
|
+
await writeFile(projectFile, `${JSON.stringify({
|
|
272
|
+
...spec,
|
|
273
|
+
backlogSource: 'project/backlog.json',
|
|
274
|
+
acceptanceLedger: 'project/acceptance-ledger.json',
|
|
275
|
+
terminalContract: 'project/terminal.md',
|
|
276
|
+
actionPolicy: {
|
|
277
|
+
deploy: 'standing_authorization_openreel_2026-08-19',
|
|
278
|
+
productionConfig: 'standing_authorization_openreel_2026-08-19',
|
|
279
|
+
backupRestoreRollbackRehearsal: 'standing_authorization_openreel_2026-08-19'
|
|
280
|
+
}
|
|
281
|
+
}, null, 2)}\n`);
|
|
282
|
+
const coveredBlocker = await enqueueTask(root, { queue, title: 'OpenReel covered production blocker', task: 'Deploy accepted candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
283
|
+
const coveredContract = await writeTaskContract(root, queue, coveredBlocker.task);
|
|
284
|
+
assert.equal(coveredContract.contract.constraints.project_authorization.production_authorized, true);
|
|
285
|
+
const coveredDir = path.join(taskRuntimeDirFor(root, queue, coveredBlocker.task.id), 'checkpoints');
|
|
286
|
+
await mkdir(coveredDir, { recursive: true });
|
|
287
|
+
await writeFile(path.join(coveredDir, 'cp1.json'), `${JSON.stringify({
|
|
288
|
+
version: 1, task_id: coveredBlocker.task.id, checkpoint_id: 'cp1', status: 'needs_human_input',
|
|
289
|
+
blockers: [{ action: 'Back up, deploy, restart, verify readiness and rehearse rollback on the established production target.', required_authority: 'Separate process-control confirmation.' }],
|
|
290
|
+
verification: ['candidate accepted'], risks: [], project_completion: { status: 'in_progress' }
|
|
291
|
+
}, null, 2)}\n`);
|
|
292
|
+
const coveredNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
293
|
+
assert.equal(coveredNotice.results.some((item) => item.taskId === coveredBlocker.task.id), false, 'standing-authorized production blocker must not create a gate');
|
|
294
|
+
|
|
295
|
+
// Explicitly authorized blocker metadata is also non-materializable, while a
|
|
296
|
+
// genuinely missing publication permission remains a human gate.
|
|
297
|
+
const publication = await enqueueTask(root, { queue, title: 'OpenReel publication blocker', task: 'Publish candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
298
|
+
await writeTaskContract(root, queue, publication.task);
|
|
299
|
+
const publicationDir = path.join(taskRuntimeDirFor(root, queue, publication.task.id), 'checkpoints');
|
|
300
|
+
await mkdir(publicationDir, { recursive: true });
|
|
301
|
+
await writeFile(path.join(publicationDir, 'cp1.json'), `${JSON.stringify({
|
|
302
|
+
version: 1, task_id: publication.task.id, checkpoint_id: 'cp1', status: 'needs_human_input',
|
|
303
|
+
blockers: [{ action: 'Publish externally now.', required_authority: 'Owner publication confirmation.', authorization_state: 'missing', needed_when: 'now' }],
|
|
304
|
+
verification: ['candidate ready'], risks: [], project_completion: { status: 'in_progress' }
|
|
305
|
+
}, null, 2)}\n`);
|
|
306
|
+
const publicationNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
307
|
+
assert.equal(publicationNotice.results.find((item) => item.taskId === publication.task.id)?.outcome, 'sent', 'missing publication authority must create a gate');
|
|
308
|
+
await resolveHumanInput(root, { queue, gateId: `${publication.task.id}:cp1`, input: 'test resolution' });
|
|
309
|
+
await reconcileProjectGates(root, { queue });
|
|
310
|
+
|
|
190
311
|
// Once the authoritative project ledger accepts the terminal contract, an
|
|
191
312
|
// optional post-completion deferred action stays in operations backlog and
|
|
192
313
|
// neither creates nor retains a project-queue waiting gate.
|
|
@@ -223,4 +344,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
|
|
|
223
344
|
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
224
345
|
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
225
346
|
|
|
226
|
-
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
347
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'authorized and consumed authority do not create gates', 'checkpoint-bound subproject backlog remains actionable', 'missing current authority creates a gate', 'standing-authorized production blocker does not create a gate', 'missing publication blocker creates a gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
@@ -548,7 +548,10 @@ for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['late
|
|
|
548
548
|
});
|
|
549
549
|
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
|
|
550
550
|
version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
|
|
551
|
-
status: 'ready_for_acceptance', blockers: [], deferred_gates: [{
|
|
551
|
+
status: 'ready_for_acceptance', blockers: [], deferred_gates: [{
|
|
552
|
+
id: 'R-1', action: 'approve_requirement', required_authority: 'Approve R-1.',
|
|
553
|
+
authorization_state: 'missing', needed_when: 'now', materialize: true
|
|
554
|
+
}],
|
|
552
555
|
verification: [], risks: [], project_completion: 'in_progress', next_action: 'wait'
|
|
553
556
|
});
|
|
554
557
|
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'final_judgement.json'), {
|