@opengsd/gsd-pi 1.3.0-dev.965d1788 → 1.3.0-dev.cca9c184
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/dist/cli.js +17 -4
- package/dist/headless-answers.js +1 -1
- package/dist/headless-ui.d.ts +1 -1
- package/dist/headless-ui.js +1 -1
- package/dist/headless.js +1 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/claude-code-cli/models.js +9 -0
- package/dist/resources/extensions/gsd/auto/session.js +8 -0
- package/dist/resources/extensions/gsd/auto-budget.js +5 -1
- package/dist/resources/extensions/gsd/auto-dispatch.js +14 -1
- package/dist/resources/extensions/gsd/auto-prompts.js +3 -3
- package/dist/resources/extensions/gsd/auto-recovery.js +3 -3
- package/dist/resources/extensions/gsd/auto.js +19 -10
- package/dist/resources/extensions/gsd/bootstrap/core-session-tools.js +1 -0
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +5 -4
- package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +25 -2
- package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +32 -4
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +15 -4
- package/dist/resources/extensions/gsd/commands/handlers/auto.js +1 -1
- package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
- package/dist/resources/extensions/gsd/commands-usage.js +1 -1
- package/dist/resources/extensions/gsd/db/engine.js +4 -1
- package/dist/resources/extensions/gsd/db-memory-fts-schema.js +34 -0
- package/dist/resources/extensions/gsd/delegation-policy.js +1 -1
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
- package/dist/resources/extensions/gsd/doctor-engine-checks.js +192 -4
- package/dist/resources/extensions/gsd/exec-sandbox.js +8 -2
- package/dist/resources/extensions/gsd/git-service.js +56 -0
- package/dist/resources/extensions/gsd/gitignore.js +1 -1
- package/dist/resources/extensions/gsd/migrate-external.js +11 -2
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +3 -3
- package/dist/resources/extensions/gsd/root-write-leak-guard.js +7 -1
- package/dist/resources/extensions/gsd/safety/destructive-guard.js +8 -1
- package/dist/resources/extensions/gsd/safety/evidence-collector.js +16 -1
- package/dist/resources/extensions/gsd/tools/plan-slice.js +59 -44
- package/dist/resources/extensions/gsd/tools/plan-task.js +28 -5
- package/dist/resources/extensions/gsd/unit-registry.js +4 -2
- package/dist/resources/extensions/gsd/workflow-manifest.js +122 -12
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
- package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/cloud-mcp-gateway/package.json +2 -2
- package/packages/contracts/package.json +1 -1
- package/packages/daemon/package.json +4 -4
- package/packages/gsd-agent-core/package.json +5 -5
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts +2 -0
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js +23 -5
- package/packages/gsd-agent-modes/dist/modes/rpc/rpc-client.js.map +1 -1
- package/packages/gsd-agent-modes/package.json +7 -7
- package/packages/mcp-server/dist/workflow-tools.js +1 -1
- package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
- package/packages/mcp-server/package.json +4 -4
- package/packages/native/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +3 -2
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/package.json +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +46 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +38 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/package.json +1 -1
- package/packages/pi-coding-agent/package.json +7 -7
- package/packages/pi-tui/package.json +2 -2
- package/packages/rpc-client/package.json +2 -2
- package/pkg/package.json +1 -1
- package/src/resources/extensions/claude-code-cli/models.ts +9 -0
- package/src/resources/extensions/claude-code-cli/tests/index.test.ts +14 -0
- package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -0
- package/src/resources/extensions/gsd/auto/session.ts +8 -0
- package/src/resources/extensions/gsd/auto-budget.ts +12 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +14 -1
- package/src/resources/extensions/gsd/auto-prompts.ts +3 -3
- package/src/resources/extensions/gsd/auto-recovery.ts +3 -3
- package/src/resources/extensions/gsd/auto.ts +23 -8
- package/src/resources/extensions/gsd/bootstrap/core-session-tools.ts +1 -0
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +5 -4
- package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -2
- package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +49 -1
- package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +34 -4
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +16 -4
- package/src/resources/extensions/gsd/commands/handlers/auto.ts +1 -1
- package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
- package/src/resources/extensions/gsd/commands-usage.ts +1 -1
- package/src/resources/extensions/gsd/db/engine.ts +8 -1
- package/src/resources/extensions/gsd/db-memory-fts-schema.ts +45 -0
- package/src/resources/extensions/gsd/delegation-policy.ts +1 -1
- package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
- package/src/resources/extensions/gsd/doctor-engine-checks.ts +233 -5
- package/src/resources/extensions/gsd/doctor-types.ts +3 -0
- package/src/resources/extensions/gsd/exec-sandbox.ts +7 -2
- package/src/resources/extensions/gsd/git-service.ts +63 -0
- package/src/resources/extensions/gsd/gitignore.ts +1 -1
- package/src/resources/extensions/gsd/migrate-external.ts +8 -2
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +2 -2
- package/src/resources/extensions/gsd/prompts/plan-slice.md +3 -3
- package/src/resources/extensions/gsd/root-write-leak-guard.ts +13 -1
- package/src/resources/extensions/gsd/safety/destructive-guard.ts +9 -1
- package/src/resources/extensions/gsd/safety/evidence-collector.ts +16 -1
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +14 -5
- package/src/resources/extensions/gsd/tests/auto-pause-double-entry-guard.test.ts +36 -0
- package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +182 -4
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +35 -0
- package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +30 -0
- package/src/resources/extensions/gsd/tests/db-memory-fts-schema.test.ts +58 -3
- package/src/resources/extensions/gsd/tests/destructive-guard.test.ts +44 -0
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +143 -0
- package/src/resources/extensions/gsd/tests/doctor-scope-db-unavailable.test.ts +239 -2
- package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +16 -0
- package/src/resources/extensions/gsd/tests/memory-store.test.ts +48 -0
- package/src/resources/extensions/gsd/tests/migrate-external-worktree.test.ts +57 -0
- package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +2 -0
- package/src/resources/extensions/gsd/tests/plan-slice.test.ts +103 -4
- package/src/resources/extensions/gsd/tests/plan-task.test.ts +44 -1
- package/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts +4 -2
- package/src/resources/extensions/gsd/tests/prompt-budget-enforcement.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +8 -6
- package/src/resources/extensions/gsd/tests/register-hooks-loop-guard-auto.test.ts +103 -0
- package/src/resources/extensions/gsd/tests/root-write-leak-guard.test.ts +20 -1
- package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +8 -3
- package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +62 -5
- package/src/resources/extensions/gsd/tests/tool-param-optionality.test.ts +6 -3
- package/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts +38 -0
- package/src/resources/extensions/gsd/tests/workflow-manifest.test.ts +44 -17
- package/src/resources/extensions/gsd/tests/workflow-mcp-preflight.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +8 -4
- package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +6 -5
- package/src/resources/extensions/gsd/tools/plan-slice.ts +60 -47
- package/src/resources/extensions/gsd/tools/plan-task.ts +30 -5
- package/src/resources/extensions/gsd/unit-registry.ts +4 -2
- package/src/resources/extensions/gsd/workflow-manifest.ts +137 -6
- /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{-ji77QJck2IKIwrGza1Bs → Eud_8lddsyi_13HIlB2i7}/_ssgManifest.js +0 -0
|
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
|
|
|
8
8
|
|
|
9
9
|
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getSlice, getSliceTasks, getTask, getGateResults, updateTaskStatus } from '../gsd-db.ts';
|
|
10
10
|
import { handlePlanSlice } from '../tools/plan-slice.ts';
|
|
11
|
+
import { handlePlanTask } from '../tools/plan-task.ts';
|
|
11
12
|
import { parsePlan } from '../parsers-legacy.ts';
|
|
12
13
|
import { deriveState, invalidateStateCache } from '../state.ts';
|
|
13
14
|
|
|
@@ -511,15 +512,113 @@ test('handlePlanSlice leaves omitted enrichment fields empty instead of renderin
|
|
|
511
512
|
}
|
|
512
513
|
});
|
|
513
514
|
|
|
514
|
-
test('handlePlanSlice
|
|
515
|
+
test('handlePlanSlice accepts metadata-only payloads without deleting existing tasks', async () => {
|
|
515
516
|
const base = makeTmpBase();
|
|
516
517
|
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
517
518
|
|
|
518
519
|
try {
|
|
519
520
|
seedParentSlice();
|
|
520
|
-
const
|
|
521
|
-
assert.ok('error' in
|
|
522
|
-
assert.
|
|
521
|
+
const first = await handlePlanSlice(validParams(), base);
|
|
522
|
+
assert.ok(!('error' in first), `unexpected error: ${'error' in first ? first.error : ''}`);
|
|
523
|
+
assert.equal(getSliceTasks('M001', 'S02').length, 2);
|
|
524
|
+
|
|
525
|
+
const second = await handlePlanSlice({
|
|
526
|
+
milestoneId: 'M001',
|
|
527
|
+
sliceId: 'S02',
|
|
528
|
+
goal: 'Persist updated slice metadata before incremental tasks.',
|
|
529
|
+
successCriteria: '- Metadata renders before task planning',
|
|
530
|
+
proofLevel: 'unit',
|
|
531
|
+
integrationClosure: 'Task details follow through gsd_plan_task.',
|
|
532
|
+
observabilityImpact: 'Progress survives between tool calls.',
|
|
533
|
+
}, base);
|
|
534
|
+
assert.ok(!('error' in second), `unexpected error: ${'error' in second ? second.error : ''}`);
|
|
535
|
+
assert.equal(getSlice('M001', 'S02')?.goal, 'Persist updated slice metadata before incremental tasks.');
|
|
536
|
+
assert.deepEqual(getSliceTasks('M001', 'S02').map((task) => task.id), ['T01', 'T02']);
|
|
537
|
+
|
|
538
|
+
const third = await handlePlanSlice({ ...validParams(), tasks: [] }, base);
|
|
539
|
+
assert.ok(!('error' in third), `unexpected error: ${'error' in third ? third.error : ''}`);
|
|
540
|
+
assert.deepEqual(getSliceTasks('M001', 'S02').map((task) => task.id), ['T01', 'T02']);
|
|
541
|
+
} finally {
|
|
542
|
+
cleanup(base);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('handlePlanSlice metadata-only on a fresh sketch slice keeps is_sketch set and renders no PLAN.md (#1027)', async () => {
|
|
547
|
+
const base = makeTmpBase();
|
|
548
|
+
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
|
552
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.', isSketch: true });
|
|
553
|
+
|
|
554
|
+
// The incremental flow's first call carries metadata only (no tasks). It
|
|
555
|
+
// must NOT error (renderPlanFromDb throws on a task-less slice), and it must
|
|
556
|
+
// NOT clear the sketch flag — clearing it without a PLAN.md would leave the
|
|
557
|
+
// slice out of refining with no plan artifact, breaking sketch/plan signaling.
|
|
558
|
+
const result = await handlePlanSlice({
|
|
559
|
+
milestoneId: 'M001',
|
|
560
|
+
sliceId: 'S02',
|
|
561
|
+
goal: 'Persist slice metadata before any tasks exist.',
|
|
562
|
+
}, base);
|
|
563
|
+
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
|
|
564
|
+
assert.equal(getSlice('M001', 'S02')?.goal, 'Persist slice metadata before any tasks exist.');
|
|
565
|
+
assert.equal(getSliceTasks('M001', 'S02').length, 0);
|
|
566
|
+
assert.equal(getSlice('M001', 'S02')?.is_sketch, 1, 'sketch flag must stay set until a task-bearing plan renders');
|
|
567
|
+
|
|
568
|
+
const planPath = join(base, '.gsd', 'phases', '01-test', '01-02-PLAN.md');
|
|
569
|
+
assert.ok(!existsSync(planPath), 'no PLAN.md should be rendered until the first task is planned');
|
|
570
|
+
|
|
571
|
+
invalidateStateCache();
|
|
572
|
+
const after = await deriveState(base);
|
|
573
|
+
assert.equal(after.phase, 'refining', 'a task-less sketch slice stays in refining');
|
|
574
|
+
} finally {
|
|
575
|
+
cleanup(base);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('handlePlanTask materializes the slice PLAN.md and clears the sketch flag on the incremental path (#1027)', async () => {
|
|
580
|
+
const base = makeTmpBase();
|
|
581
|
+
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
|
585
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.', isSketch: true });
|
|
586
|
+
|
|
587
|
+
const sliceResult = await handlePlanSlice({
|
|
588
|
+
milestoneId: 'M001',
|
|
589
|
+
sliceId: 'S02',
|
|
590
|
+
goal: 'Incrementally planned slice.',
|
|
591
|
+
successCriteria: '- The slice plan is built up one task at a time',
|
|
592
|
+
}, base);
|
|
593
|
+
assert.ok(!('error' in sliceResult), `unexpected error: ${'error' in sliceResult ? sliceResult.error : ''}`);
|
|
594
|
+
|
|
595
|
+
const taskResult = await handlePlanTask({
|
|
596
|
+
milestoneId: 'M001',
|
|
597
|
+
sliceId: 'S02',
|
|
598
|
+
taskId: 'T01',
|
|
599
|
+
title: 'First incremental task',
|
|
600
|
+
description: 'Implement the first task added through gsd_plan_task.',
|
|
601
|
+
estimate: '30m',
|
|
602
|
+
files: ['src/resources/extensions/gsd/tools/plan-task.ts'],
|
|
603
|
+
verify: 'node --test src/resources/extensions/gsd/tests/plan-task.test.ts',
|
|
604
|
+
inputs: ['src/resources/extensions/gsd/tools/plan-milestone.ts'],
|
|
605
|
+
expectedOutput: ['src/resources/extensions/gsd/tools/plan-task.ts'],
|
|
606
|
+
}, base);
|
|
607
|
+
assert.ok(!('error' in taskResult), `unexpected error: ${'error' in taskResult ? taskResult.error : ''}`);
|
|
608
|
+
|
|
609
|
+
// The first incremental task materializes the canonical slice PLAN.md with
|
|
610
|
+
// the task present, and clears the sketch flag now that a plan exists.
|
|
611
|
+
const planPath = join(base, '.gsd', 'phases', '01-test', '01-02-PLAN.md');
|
|
612
|
+
assert.ok(existsSync(planPath), 'slice PLAN.md should exist after the first task is planned');
|
|
613
|
+
const parsedPlan = parsePlan(readFileSync(planPath, 'utf-8'));
|
|
614
|
+
assert.equal(parsedPlan.goal, 'Incrementally planned slice.');
|
|
615
|
+
assert.equal(parsedPlan.tasks.length, 1);
|
|
616
|
+
assert.equal(parsedPlan.tasks[0]?.id, 'T01');
|
|
617
|
+
assert.equal(getSlice('M001', 'S02')?.is_sketch, 0, 'sketch flag must clear once the first task renders a plan');
|
|
618
|
+
|
|
619
|
+
invalidateStateCache();
|
|
620
|
+
const after = await deriveState(base);
|
|
621
|
+
assert.notEqual(after.phase, 'refining', 'a planned slice leaves refining');
|
|
523
622
|
} finally {
|
|
524
623
|
cleanup(base);
|
|
525
624
|
}
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, writeFileSync
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getTask } from '../gsd-db.ts';
|
|
7
|
+
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getSlice, getTask, getGateResults } from '../gsd-db.ts';
|
|
8
8
|
import { handlePlanTask } from '../tools/plan-task.ts';
|
|
9
9
|
import { parseTaskPlanFile } from '../files.ts';
|
|
10
10
|
|
|
@@ -65,6 +65,25 @@ test('handlePlanTask writes planning state and renders task plan', async () => {
|
|
|
65
65
|
}
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test('handlePlanTask seeds execute-task gate rows for incremental planning', async () => {
|
|
69
|
+
const base = makeTmpBase();
|
|
70
|
+
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
seedParent();
|
|
74
|
+
const result = await handlePlanTask(validParams(), base);
|
|
75
|
+
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
|
|
76
|
+
|
|
77
|
+
const gateIds = getGateResults('M001', 'S02', 'task')
|
|
78
|
+
.filter((gate) => gate.task_id === 'T02')
|
|
79
|
+
.map((gate) => gate.gate_id)
|
|
80
|
+
.sort();
|
|
81
|
+
assert.deepEqual(gateIds, ['Q5', 'Q6', 'Q7']);
|
|
82
|
+
} finally {
|
|
83
|
+
cleanup(base);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
68
87
|
test('handlePlanTask rejects invalid payloads', async () => {
|
|
69
88
|
const base = makeTmpBase();
|
|
70
89
|
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
@@ -170,6 +189,30 @@ test('handlePlanTask surfaces render failures without changing parse-visible tas
|
|
|
170
189
|
}
|
|
171
190
|
});
|
|
172
191
|
|
|
192
|
+
test('handlePlanTask keeps the sketch flag set when the flat-phase slice plan sync fails (#1083)', async () => {
|
|
193
|
+
const base = makeTmpBase();
|
|
194
|
+
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
|
|
198
|
+
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.', isSketch: true });
|
|
199
|
+
|
|
200
|
+
// The per-task render writes T02-PLAN.md and succeeds, but the flat-phase
|
|
201
|
+
// slice re-render (the canonical PLAN.md that embeds task checkboxes) targets
|
|
202
|
+
// phases/01-test/01-02-PLAN.md. Pre-creating that path as a directory forces
|
|
203
|
+
// EISDIR on the slice sync, so the canonical slice plan never reflects the
|
|
204
|
+
// task. The sketch flag must therefore stay set — the slice keeps refining —
|
|
205
|
+
// instead of leaving the slice out of refining over a stale plan.
|
|
206
|
+
mkdirSync(join(base, '.gsd', 'phases', '01-test', '01-02-PLAN.md'), { recursive: true });
|
|
207
|
+
|
|
208
|
+
const result = await handlePlanTask(validParams(), base);
|
|
209
|
+
assert.equal(getSlice('M001', 'S02')?.is_sketch, 1, 'sketch flag must stay set when the canonical slice plan could not sync');
|
|
210
|
+
assert.ok(getTask('M001', 'S02', 'T02'), 'the task itself is still persisted so the slice can re-sync on retry');
|
|
211
|
+
} finally {
|
|
212
|
+
cleanup(base);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
173
216
|
test('handlePlanTask reruns idempotently and refreshes parse-visible state', async () => {
|
|
174
217
|
const base = makeTmpBase();
|
|
175
218
|
openDatabase(join(base, '.gsd', 'gsd.db'));
|
|
@@ -10,7 +10,7 @@ import * as os from 'node:os';
|
|
|
10
10
|
import { openDatabase, closeDatabase } from '../gsd-db.ts';
|
|
11
11
|
import { handleCompleteTask } from '../tools/complete-task.ts';
|
|
12
12
|
import { readEvents } from '../workflow-events.ts';
|
|
13
|
-
import { readManifest } from '../workflow-manifest.ts';
|
|
13
|
+
import { flushManifest, readManifest } from '../workflow-manifest.ts';
|
|
14
14
|
|
|
15
15
|
function tempDir(): string {
|
|
16
16
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-post-hook-'));
|
|
@@ -115,8 +115,9 @@ test('post-mutation-hook: state-manifest.json exists after handleCompleteTask',
|
|
|
115
115
|
const result = await handleCompleteTask(makeCompleteTaskParams(), base);
|
|
116
116
|
assert.ok(!('error' in result), `handler should succeed, got: ${JSON.stringify(result)}`);
|
|
117
117
|
|
|
118
|
+
await flushManifest(base);
|
|
118
119
|
const manifestPath = path.join(base, '.gsd', 'state-manifest.json');
|
|
119
|
-
assert.ok(fs.existsSync(manifestPath), 'state-manifest.json should exist after
|
|
120
|
+
assert.ok(fs.existsSync(manifestPath), 'state-manifest.json should exist after manifest flush');
|
|
120
121
|
} finally {
|
|
121
122
|
closeDatabase();
|
|
122
123
|
cleanupDir(base);
|
|
@@ -131,6 +132,7 @@ test('post-mutation-hook: manifest has version 1 and includes completed task', a
|
|
|
131
132
|
|
|
132
133
|
try {
|
|
133
134
|
await handleCompleteTask(makeCompleteTaskParams(), base);
|
|
135
|
+
await flushManifest(base);
|
|
134
136
|
|
|
135
137
|
const manifest = readManifest(base);
|
|
136
138
|
assert.ok(manifest !== null, 'manifest should be readable');
|
|
@@ -72,6 +72,25 @@ function setupDependencyFixture(
|
|
|
72
72
|
mkdirSync(targetSliceDir, { recursive: true });
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
function setupFlatPhaseSlicePlan(base: string): void {
|
|
76
|
+
const phaseDir = join(base, ".gsd", "phases", "01-flat-phase");
|
|
77
|
+
mkdirSync(phaseDir, { recursive: true });
|
|
78
|
+
writeFileSync(
|
|
79
|
+
join(phaseDir, "01-01-PLAN.md"),
|
|
80
|
+
[
|
|
81
|
+
"# S01: Flat phase slice",
|
|
82
|
+
"",
|
|
83
|
+
"## Tasks",
|
|
84
|
+
"",
|
|
85
|
+
"- [ ] **T01: Flat task** `est:15m`",
|
|
86
|
+
"",
|
|
87
|
+
"## Verification",
|
|
88
|
+
"",
|
|
89
|
+
"- Rendered prompt points at this slice plan.",
|
|
90
|
+
].join("\n"),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
// ─── inlineDependencySummaries truncation ─────────────────────────────────────
|
|
76
95
|
|
|
77
96
|
describe("prompt-budget: inlineDependencySummaries truncation", () => {
|
|
@@ -412,6 +431,26 @@ describe("prompt-budget: execute-task template", () => {
|
|
|
412
431
|
}
|
|
413
432
|
});
|
|
414
433
|
|
|
434
|
+
it("flat-phase execute-task fallback points at the slice plan", async () => {
|
|
435
|
+
const base = createFixtureBase();
|
|
436
|
+
try {
|
|
437
|
+
setupFlatPhaseSlicePlan(base);
|
|
438
|
+
|
|
439
|
+
const prompt = await buildExecuteTaskPrompt("M001", "S01", "Slice", "T01", "Task", base, {
|
|
440
|
+
level: "minimal",
|
|
441
|
+
sessionContextWindow: 128_000,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
assert.match(
|
|
445
|
+
prompt,
|
|
446
|
+
/Task plan not found at dispatch time\. Read `\.gsd\/phases\/01-flat-phase\/01-01-PLAN\.md` before executing\./,
|
|
447
|
+
);
|
|
448
|
+
assert.doesNotMatch(prompt, /\.gsd\/phases\/01-flat-phase\/tasks\/T01-PLAN\.md/);
|
|
449
|
+
} finally {
|
|
450
|
+
cleanup(base);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
415
454
|
it("verificationBudget format varies with context window size", () => {
|
|
416
455
|
const budget128K = computeBudgets(128_000);
|
|
417
456
|
const budget1M = computeBudgets(1_000_000);
|
|
@@ -566,6 +605,25 @@ describe("prompt-budget: execute-task builder truncation pattern", () => {
|
|
|
566
605
|
});
|
|
567
606
|
|
|
568
607
|
describe("prompt-budget: reactive-execute builder", () => {
|
|
608
|
+
it("flat-phase subagent fallback points at the slice plan", async () => {
|
|
609
|
+
const base = createFixtureBase();
|
|
610
|
+
try {
|
|
611
|
+
setupFlatPhaseSlicePlan(base);
|
|
612
|
+
|
|
613
|
+
const prompt = await buildReactiveExecutePrompt("M001", "Milestone", "S01", "Slice", ["T01"], base, undefined, {
|
|
614
|
+
sessionContextWindow: 128_000,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
assert.match(
|
|
618
|
+
prompt,
|
|
619
|
+
/Task plan not found at dispatch time\. Read `\.gsd\/phases\/01-flat-phase\/01-01-PLAN\.md` before executing\./,
|
|
620
|
+
);
|
|
621
|
+
assert.doesNotMatch(prompt, /\.gsd\/phases\/01-flat-phase\/tasks\/T01-PLAN\.md/);
|
|
622
|
+
} finally {
|
|
623
|
+
cleanup(base);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
569
627
|
it("caps dependency carry-forward per ready subagent", async () => {
|
|
570
628
|
const base = createFixtureBase();
|
|
571
629
|
try {
|
|
@@ -533,6 +533,7 @@ test("plan-milestone prompt references DB-backed planning tool and explicitly fo
|
|
|
533
533
|
assert.match(prompt, /Do \*\*not\*\* write `?\{\{outputPath\}\}`?, `?ROADMAP\.md`?, or other planning artifacts manually/i);
|
|
534
534
|
assert.match(prompt, /NEVER call `gsd_plan_milestone` with only `milestoneId` and `sliceId`/);
|
|
535
535
|
assert.match(prompt, /gsd_plan_slice/);
|
|
536
|
+
assert.match(prompt, /gsd_plan_task/);
|
|
536
537
|
});
|
|
537
538
|
|
|
538
539
|
test("discuss prompts forbid gsd_plan_milestone slice-only tool args", () => {
|
|
@@ -548,12 +549,12 @@ test("plan-slice prompt no longer frames direct PLAN writes as the source of tru
|
|
|
548
549
|
assert.match(prompt, /Do \*\*not\*\* rely on direct `PLAN\.md` writes as the source of truth/i);
|
|
549
550
|
});
|
|
550
551
|
|
|
551
|
-
test("plan-slice prompt explicitly names
|
|
552
|
+
test("plan-slice prompt explicitly names incremental DB-backed planning tools", () => {
|
|
552
553
|
const prompt = readPrompt("plan-slice");
|
|
553
554
|
assert.match(prompt, /gsd_plan_slice/);
|
|
554
555
|
assert.match(prompt, /gsd_plan_task/);
|
|
555
|
-
|
|
556
|
-
assert.match(prompt, /DB-backed
|
|
556
|
+
assert.match(prompt, /Persist incrementally through DB-backed tools/i);
|
|
557
|
+
assert.match(prompt, /DB-backed tools are the canonical write path/i);
|
|
557
558
|
});
|
|
558
559
|
|
|
559
560
|
test("plan-slice prompt does not instruct direct file writes as a primary step", () => {
|
|
@@ -562,11 +563,12 @@ test("plan-slice prompt does not instruct direct file writes as a primary step",
|
|
|
562
563
|
assert.doesNotMatch(prompt, /^\d+\.\s+Write `?\{\{outputPath\}\}`?\s*$/m);
|
|
563
564
|
});
|
|
564
565
|
|
|
565
|
-
test("plan-slice prompt
|
|
566
|
+
test("plan-slice prompt requires per-task incremental persistence", () => {
|
|
566
567
|
const prompt = readPrompt("plan-slice");
|
|
567
|
-
// gsd_plan_slice persists tasks in its transaction — no separate gsd_plan_task calls needed
|
|
568
568
|
assert.match(prompt, /gsd_plan_task/);
|
|
569
|
-
assert.match(prompt, /
|
|
569
|
+
assert.match(prompt, /omit `tasks` for this metadata call/i);
|
|
570
|
+
assert.match(prompt, /call `gsd_plan_task` once per task/i);
|
|
571
|
+
assert.doesNotMatch(prompt, /do not call `gsd_plan_task`/i);
|
|
570
572
|
});
|
|
571
573
|
|
|
572
574
|
test("plan-slice prompt references web app UAT guidance when planning browser work", () => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { autoSession } from "../auto-runtime-state.ts";
|
|
5
|
+
import { registerHooks } from "../bootstrap/register-hooks.ts";
|
|
6
|
+
import { resetToolCallLoopGuard } from "../bootstrap/tool-call-loop-guard.ts";
|
|
7
|
+
|
|
8
|
+
type Handler = (event: any, ctx?: any) => Promise<any> | any;
|
|
9
|
+
|
|
10
|
+
function makeHookHarness(): {
|
|
11
|
+
emitToolCall: (toolName: string, input: Record<string, unknown>) => Promise<any>;
|
|
12
|
+
} {
|
|
13
|
+
const handlers = new Map<string, Handler[]>();
|
|
14
|
+
const pi = {
|
|
15
|
+
on(event: string, handler: Handler) {
|
|
16
|
+
const existing = handlers.get(event) ?? [];
|
|
17
|
+
existing.push(handler);
|
|
18
|
+
handlers.set(event, existing);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const ctx = {
|
|
22
|
+
cwd: process.cwd(),
|
|
23
|
+
ui: { notify: () => undefined },
|
|
24
|
+
};
|
|
25
|
+
let callId = 0;
|
|
26
|
+
|
|
27
|
+
registerHooks(pi as any, []);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async emitToolCall(toolName: string, input: Record<string, unknown>): Promise<any> {
|
|
31
|
+
callId += 1;
|
|
32
|
+
const loopGuardHandler = handlers.get("tool_call")?.[0];
|
|
33
|
+
assert.ok(loopGuardHandler, "loop-guard tool_call handler should be registered");
|
|
34
|
+
return loopGuardHandler({ toolCallId: `loop-${callId}`, toolName, input }, ctx);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("register-hooks keeps loop-guard block reason interactive outside auto-mode", async (t) => {
|
|
40
|
+
autoSession.reset();
|
|
41
|
+
resetToolCallLoopGuard();
|
|
42
|
+
t.after(() => {
|
|
43
|
+
autoSession.reset();
|
|
44
|
+
resetToolCallLoopGuard();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { emitToolCall } = makeHookHarness();
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < 4; i += 1) {
|
|
50
|
+
await emitToolCall("web_search", { query: "same query" });
|
|
51
|
+
}
|
|
52
|
+
const block = await emitToolCall("web_search", { query: "same query" });
|
|
53
|
+
|
|
54
|
+
assert.equal(block?.block, true);
|
|
55
|
+
assert.match(block.reason, /respond to the user in text/);
|
|
56
|
+
assert.doesNotMatch(block.reason, /auto-mode recovery\/replan path/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("register-hooks rewrites loop-guard block reason for auto-mode identical-args blocks", async (t) => {
|
|
60
|
+
autoSession.reset();
|
|
61
|
+
resetToolCallLoopGuard();
|
|
62
|
+
autoSession.active = true;
|
|
63
|
+
t.after(() => {
|
|
64
|
+
autoSession.reset();
|
|
65
|
+
resetToolCallLoopGuard();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const { emitToolCall } = makeHookHarness();
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < 4; i += 1) {
|
|
71
|
+
await emitToolCall("web_search", { query: "same query" });
|
|
72
|
+
}
|
|
73
|
+
const block = await emitToolCall("web_search", { query: "same query" });
|
|
74
|
+
|
|
75
|
+
assert.equal(block?.block, true);
|
|
76
|
+
assert.match(block.reason, /Tool loop detected \(identical args\): web_search/);
|
|
77
|
+
assert.match(block.reason, /Do not re-issue this blocked tool/);
|
|
78
|
+
assert.match(block.reason, /auto-mode recovery\/replan path/);
|
|
79
|
+
assert.doesNotMatch(block.reason, /respond to the user in text/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("register-hooks rewrites loop-guard block reason for auto-mode per-tool blocks", async (t) => {
|
|
83
|
+
autoSession.reset();
|
|
84
|
+
resetToolCallLoopGuard();
|
|
85
|
+
autoSession.active = true;
|
|
86
|
+
t.after(() => {
|
|
87
|
+
autoSession.reset();
|
|
88
|
+
resetToolCallLoopGuard();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { emitToolCall } = makeHookHarness();
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < 6; i += 1) {
|
|
94
|
+
await emitToolCall("gsd_complete_milestone", { milestone: `M${i}` });
|
|
95
|
+
}
|
|
96
|
+
const block = await emitToolCall("gsd_complete_milestone", { milestone: "M7" });
|
|
97
|
+
|
|
98
|
+
assert.equal(block?.block, true);
|
|
99
|
+
assert.match(block.reason, /Tool loop detected \(repeated tool\): gsd_complete_milestone/);
|
|
100
|
+
assert.match(block.reason, /Do not re-issue this blocked tool/);
|
|
101
|
+
assert.match(block.reason, /auto-mode recovery\/replan path/);
|
|
102
|
+
assert.doesNotMatch(block.reason, /respond to the user in text/);
|
|
103
|
+
});
|
|
@@ -7,7 +7,26 @@ import { closeSync, ftruncateSync, openSync, statSync, writeFileSync } from "nod
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
9
|
import { captureRootDirtySnapshot } from "../root-write-leak-guard.ts";
|
|
10
|
-
import { cleanup, git, makeTempRepo } from "./test-utils.ts";
|
|
10
|
+
import { cleanup, createFile, git, makeTempRepo } from "./test-utils.ts";
|
|
11
|
+
|
|
12
|
+
test("captureRootDirtySnapshot ignores unignored GSD root runtime directories", () => {
|
|
13
|
+
const base = makeTempRepo("gsd-root-dirty-runtime-");
|
|
14
|
+
try {
|
|
15
|
+
createFile(base, ".gsd/STATE.md", "state\n");
|
|
16
|
+
createFile(base, ".gsd-backups/snap-001", "backup\n");
|
|
17
|
+
createFile(base, ".gsd-worktrees/M001/file.txt", "worktree\n");
|
|
18
|
+
createFile(base, "src/foo.ts", "export {};\n");
|
|
19
|
+
|
|
20
|
+
const snapshot = captureRootDirtySnapshot(base);
|
|
21
|
+
|
|
22
|
+
assert.equal(snapshot.has(".gsd/STATE.md"), false);
|
|
23
|
+
assert.equal(snapshot.has(".gsd-backups/snap-001"), false);
|
|
24
|
+
assert.equal(snapshot.has(".gsd-worktrees/M001/file.txt"), false);
|
|
25
|
+
assert.equal(snapshot.has("src/foo.ts"), true);
|
|
26
|
+
} finally {
|
|
27
|
+
cleanup(base);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
11
30
|
|
|
12
31
|
test("captureRootDirtySnapshot does not read dirty files larger than Node's Buffer limit", () => {
|
|
13
32
|
const base = makeTempRepo("gsd-root-dirty-large-");
|
|
@@ -184,10 +184,15 @@ test("auto Unit tool scope allows reactive-execute status and diagnostic tools",
|
|
|
184
184
|
}
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
test("auto Unit tool scope
|
|
188
|
-
|
|
187
|
+
test("auto Unit tool scope allows incremental task planning during plan-slice", () => {
|
|
188
|
+
const result = shouldBlockAutoUnitToolCall("plan-slice", "gsd_plan_task");
|
|
189
|
+
assert.equal(result.block, false, "plan-slice should be able to call gsd_plan_task incrementally");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("auto Unit tool scope still blocks per-task planner outside plan-slice", () => {
|
|
193
|
+
for (const unitType of ["refine-slice", "replan-slice"]) {
|
|
189
194
|
const result = shouldBlockAutoUnitToolCall(unitType, "gsd_plan_task");
|
|
190
|
-
assert.equal(result.block, true, `${unitType} should not call
|
|
195
|
+
assert.equal(result.block, true, `${unitType} should not call gsd_plan_task`);
|
|
191
196
|
}
|
|
192
197
|
});
|
|
193
198
|
|
|
@@ -7,6 +7,7 @@ import { test } from 'node:test';
|
|
|
7
7
|
import assert from 'node:assert/strict';
|
|
8
8
|
import {
|
|
9
9
|
checkToolCallLoop,
|
|
10
|
+
recordToolCallLoopMutation,
|
|
10
11
|
resetToolCallLoopGuard,
|
|
11
12
|
disableToolCallLoopGuard,
|
|
12
13
|
getToolCallLoopCount,
|
|
@@ -223,6 +224,62 @@ console.log('\n── Loop guard: repeatable tools get the higher cap (#783) ─
|
|
|
223
224
|
assert.ok(blocked.reason!.includes('cap 15'), 'reason should mention the repeatable cap');
|
|
224
225
|
}
|
|
225
226
|
|
|
227
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
228
|
+
// Mutation progress decays per-tool counts (#1092)
|
|
229
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
230
|
+
|
|
231
|
+
console.log('\n── Loop guard: mutation progress decays per-tool counts (#1092) ──');
|
|
232
|
+
|
|
233
|
+
{
|
|
234
|
+
resetToolCallLoopGuard();
|
|
235
|
+
|
|
236
|
+
// bash is called more than the repeatable cap, but each count is separated
|
|
237
|
+
// by a successful file mutation. Guard 2 should treat that as progress and
|
|
238
|
+
// avoid blocking the progressing unit.
|
|
239
|
+
for (let i = 1; i <= 20; i++) {
|
|
240
|
+
const result = checkToolCallLoop('bash', { command: `echo ${i}` });
|
|
241
|
+
assert.ok(result.block === false, `bash call ${i} after mutation progress should be allowed`);
|
|
242
|
+
assert.deepStrictEqual(getToolCallCountForTool('bash'), 1, `bash count should decay before call ${i}`);
|
|
243
|
+
|
|
244
|
+
const mutationTool = i % 2 === 0 ? 'write' : 'edit';
|
|
245
|
+
const mutation = checkToolCallLoop(mutationTool, {
|
|
246
|
+
path: `file-${i}.ts`,
|
|
247
|
+
content: `content ${i}`,
|
|
248
|
+
});
|
|
249
|
+
assert.ok(mutation.block === false, `${mutationTool} mutation ${i} should be allowed`);
|
|
250
|
+
recordToolCallLoopMutation(mutationTool);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
255
|
+
// Distinct read-only navigation calls are context gathering, not repeated-tool loops.
|
|
256
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
console.log('\n── Loop guard: distinct read-only navigation calls are not capped by per-tool count ──');
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
const exemptTools = ['find', 'glob', 'grep', 'ls', 'read', 'search_and_read'];
|
|
262
|
+
|
|
263
|
+
for (const toolName of exemptTools) {
|
|
264
|
+
resetToolCallLoopGuard();
|
|
265
|
+
|
|
266
|
+
for (let i = 1; i <= 20; i++) {
|
|
267
|
+
const result = checkToolCallLoop(toolName, { path: `file-${i}.ts` });
|
|
268
|
+
assert.ok(result.block === false, `distinct ${toolName} call ${i} should be allowed`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
resetToolCallLoopGuard();
|
|
272
|
+
for (let i = 1; i <= 4; i++) {
|
|
273
|
+
const result = checkToolCallLoop(toolName, { path: 'same-file.ts' });
|
|
274
|
+
assert.ok(result.block === false, `identical ${toolName} call ${i} should be allowed`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const blocked = checkToolCallLoop(toolName, { path: 'same-file.ts' });
|
|
278
|
+
assert.ok(blocked.block === true, `5th identical ${toolName} call should still be blocked`);
|
|
279
|
+
assert.ok(blocked.reason?.includes('identical args'), `${toolName} loops should be caught by Guard 1`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
226
283
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
227
284
|
// Per-tool counts are independent per tool and reset together
|
|
228
285
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -245,15 +302,15 @@ console.log('\n── Loop guard: per-tool counts are independent and reset toge
|
|
|
245
302
|
}
|
|
246
303
|
|
|
247
304
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
305
|
+
// Non-exempt repeatable tools from core-session-tools.ts get the higher cap.
|
|
306
|
+
// Read-only navigation tools are exempted earlier; mutating/execution repeatable
|
|
307
|
+
// tools (e.g. gsd_exec) must still trip the per-tool cap.
|
|
251
308
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
252
309
|
|
|
253
|
-
console.log('\n── Loop guard:
|
|
310
|
+
console.log('\n── Loop guard: non-exempt repeatable tools get high cap ──');
|
|
254
311
|
|
|
255
312
|
{
|
|
256
|
-
for (const toolName of ['bg_shell', '
|
|
313
|
+
for (const toolName of ['bg_shell', 'gsd_exec', 'write']) {
|
|
257
314
|
resetToolCallLoopGuard();
|
|
258
315
|
|
|
259
316
|
// Should allow up to 15 varied calls without blocking.
|
|
@@ -385,14 +385,17 @@ test("gsd_plan_slice — enrichment fields are optional", () => {
|
|
|
385
385
|
|
|
386
386
|
const required = new Set(getRequiredProps(tool));
|
|
387
387
|
|
|
388
|
-
// Core fields
|
|
389
|
-
|
|
388
|
+
// Core fields. `tasks` is intentionally NOT here: incremental planning
|
|
389
|
+
// (#1027) lets gsd_plan_slice persist slice metadata only, then add tasks
|
|
390
|
+
// one at a time via gsd_plan_task, so tasks must be optional.
|
|
391
|
+
const coreRequired = ["milestoneId", "sliceId", "goal"];
|
|
390
392
|
for (const field of coreRequired) {
|
|
391
393
|
assert.ok(required.has(field), `core field "${field}" must be required`);
|
|
392
394
|
}
|
|
393
395
|
|
|
394
|
-
// Enrichment fields
|
|
396
|
+
// Enrichment fields (plus tasks, optional for incremental planning).
|
|
395
397
|
const enrichmentFields = [
|
|
398
|
+
"tasks",
|
|
396
399
|
"successCriteria",
|
|
397
400
|
"proofLevel",
|
|
398
401
|
"integrationClosure",
|
|
@@ -18,6 +18,7 @@ import { join } from "node:path";
|
|
|
18
18
|
import { execSync } from "node:child_process";
|
|
19
19
|
import { GIT_NO_PROMPT_ENV } from "../git-constants.ts";
|
|
20
20
|
import { handleTurnGitActionError, runTurnGitAction } from "../git-service.ts";
|
|
21
|
+
import { _resetLogs, drainLogs } from "../workflow-logger.ts";
|
|
21
22
|
|
|
22
23
|
function run(cmd: string, cwd: string): string {
|
|
23
24
|
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
|
|
@@ -111,6 +112,43 @@ workspace:
|
|
|
111
112
|
}
|
|
112
113
|
});
|
|
113
114
|
|
|
115
|
+
test("uok gitops turn action status-only reports dirty undeclared nested git repositories", () => {
|
|
116
|
+
const root = mkdtempSync(join(tmpdir(), "gsd-uok-gitops-nested-"));
|
|
117
|
+
try {
|
|
118
|
+
initRepo(root);
|
|
119
|
+
mkdirSync(join(root, "nested"), { recursive: true });
|
|
120
|
+
initRepo(join(root, "nested"));
|
|
121
|
+
writeFileSync(join(root, ".gitignore"), "nested/\n", "utf-8");
|
|
122
|
+
run("git add .gitignore", root);
|
|
123
|
+
run('git commit -m "chore: ignore nested repo"', root);
|
|
124
|
+
|
|
125
|
+
writeFileSync(join(root, "nested", "README.md"), "# Dirty nested\n", "utf-8");
|
|
126
|
+
|
|
127
|
+
_resetLogs();
|
|
128
|
+
const result = runTurnGitAction({
|
|
129
|
+
basePath: root,
|
|
130
|
+
action: "status-only",
|
|
131
|
+
unitType: "execute-task",
|
|
132
|
+
unitId: "M001/S01/T01",
|
|
133
|
+
});
|
|
134
|
+
const warnings = drainLogs();
|
|
135
|
+
|
|
136
|
+
assert.equal(result.status, "ok");
|
|
137
|
+
assert.equal(result.dirty, true);
|
|
138
|
+
assert.deepEqual(result.dirtyRepositories, {
|
|
139
|
+
project: false,
|
|
140
|
+
"nested (undeclared)": true,
|
|
141
|
+
});
|
|
142
|
+
assert.ok(
|
|
143
|
+
warnings.some((entry) => entry.message.includes("undeclared dirty nested git repo nested")),
|
|
144
|
+
"dirty undeclared nested repo should be logged as a warning",
|
|
145
|
+
);
|
|
146
|
+
} finally {
|
|
147
|
+
_resetLogs();
|
|
148
|
+
rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
114
152
|
test("uok gitops turn action snapshot writes snapshot refs", () => {
|
|
115
153
|
const repo = makeRepo();
|
|
116
154
|
try {
|