@hassangameryt/freeflow 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +96 -149
- package/capabilities/cognitive-routing/SKILL.md +21 -129
- package/capabilities/cognitive-routing/references/coordinator.md +163 -0
- package/capabilities/cognitive-routing/references/worker.md +38 -0
- package/capabilities/compaction/SKILL.md +3 -3
- package/capabilities/compaction/references/summary-format.md +26 -15
- package/capabilities/messaging/SKILL.md +49 -0
- package/capabilities/task-memory/SKILL.md +87 -0
- package/command-surface.json +6 -54
- package/gemini-extension.json +1 -1
- package/hooks/claude/hooks.json +13 -3
- package/hooks/codex/hooks.json +2 -1
- package/hooks/shared/runtime-context.mjs +69 -77
- package/package.json +25 -8
- package/pi-extension/dist/ask-user/ask.js +108 -0
- package/pi-extension/dist/ask-user/form.js +250 -0
- package/pi-extension/dist/ask-user/questions.js +66 -0
- package/pi-extension/dist/ask-user/tool.js +124 -0
- package/pi-extension/dist/cognitive-routing/assembler.js +35 -11
- package/pi-extension/dist/cognitive-routing/bootstrap.js +90 -0
- package/pi-extension/dist/cognitive-routing/config.js +3 -4
- package/pi-extension/dist/cognitive-routing/event-store.js +4 -1
- package/pi-extension/dist/cognitive-routing/gate.js +20 -5
- package/pi-extension/dist/cognitive-routing/handlers.js +15 -13
- package/pi-extension/dist/cognitive-routing/model-control.js +17 -8
- package/pi-extension/dist/cognitive-routing/projection.js +23 -6
- package/pi-extension/dist/cognitive-routing/provenance.js +3 -1
- package/pi-extension/dist/cognitive-routing/render.js +15 -15
- package/pi-extension/dist/cognitive-routing/runtime.js +4 -2
- package/pi-extension/dist/cognitive-routing/schemas.js +4 -4
- package/pi-extension/dist/cognitive-routing/session.js +3 -1
- package/pi-extension/dist/cognitive-routing/sources.js +29 -4
- package/pi-extension/dist/cognitive-routing/state.js +18 -8
- package/pi-extension/dist/cognitive-routing/status.js +4 -3
- package/pi-extension/dist/cognitive-routing/tools.js +29 -15
- package/pi-extension/dist/cognitive-routing/types.js +10 -1
- package/pi-extension/dist/compaction/carry.js +36 -22
- package/pi-extension/dist/compaction/config.js +4 -2
- package/pi-extension/dist/compaction/controller.js +52 -29
- package/pi-extension/dist/compaction/harness.js +19 -3
- package/pi-extension/dist/compaction/results.js +14 -13
- package/pi-extension/dist/compaction/tool.js +6 -6
- package/pi-extension/dist/host/catalog.js +20 -35
- package/pi-extension/dist/host/config-validation.js +233 -0
- package/pi-extension/dist/host/config.js +78 -91
- package/pi-extension/dist/host/inbound.js +142 -0
- package/pi-extension/dist/host/projection-tags.js +1 -1
- package/pi-extension/dist/host/prompts.js +24 -13
- package/pi-extension/dist/host/read-only-session.js +6 -1
- package/pi-extension/dist/host/request-history.js +7 -3
- package/pi-extension/dist/host/rows.js +25 -0
- package/pi-extension/dist/host/runtime-state.js +17 -2
- package/pi-extension/dist/host/section-keeper.js +85 -0
- package/pi-extension/dist/host/settings/freeflow-command.js +846 -93
- package/pi-extension/dist/host/settings/settings-view.js +27 -8
- package/pi-extension/dist/host/status.js +19 -5
- package/pi-extension/dist/index.js +638 -72
- package/pi-extension/dist/messaging/config.js +27 -0
- package/pi-extension/dist/messaging/messaging.js +398 -0
- package/pi-extension/dist/messaging/registry.js +57 -0
- package/pi-extension/dist/messaging/render.js +90 -0
- package/pi-extension/dist/messaging/tools.js +109 -0
- package/pi-extension/dist/messaging/transport.js +101 -0
- package/pi-extension/dist/provider-support/cache/keepalive.js +16 -4
- package/pi-extension/dist/task-memory/config.js +49 -0
- package/pi-extension/dist/task-memory/registry.js +119 -0
- package/pi-extension/dist/task-memory/render.js +107 -0
- package/pi-extension/dist/task-memory/runtime.js +994 -0
- package/pi-extension/dist/task-memory/tool.js +233 -0
- package/pi-extension/dist/tool-execution/apply-patch/tool.js +1 -1
- package/pi-extension/dist/tool-execution/background.js +220 -28
- package/pi-extension/dist/tool-execution/config.js +14 -3
- package/pi-extension/dist/tool-execution/file-tracking.js +24 -4
- package/pi-extension/dist/tool-execution/guidance-notices.js +124 -0
- package/pi-extension/dist/tool-execution/skill-reads.js +143 -0
- package/pi-extension/dist/tool-execution/tools.js +2 -2
- package/runtime/prompts/cognitive-routing.md +4 -4
- package/runtime/prompts/core.md +24 -14
- package/runtime/prompts/interaction-contract.md +7 -4
- package/runtime/prompts/tool-execution.md +3 -16
- package/runtime/prompts/working-method.md +16 -99
- package/skills/deliver-work/SKILL.md +76 -0
- package/skills/deliver-work/references/branch-closeout.md +134 -0
- package/skills/deliver-work/references/commit.md +83 -0
- package/skills/deliver-work/references/launch.md +94 -0
- package/skills/deliver-work/references/release.md +85 -0
- package/skills/deliver-work/scripts/commit-state.mjs +139 -0
- package/skills/deliver-work/scripts/delivery-state.mjs +107 -0
- package/skills/deliver-work/scripts/lib/git-state.mjs +178 -0
- package/skills/deliver-work/scripts/push-route.mjs +122 -0
- package/skills/deliver-work/scripts/release-preflight.mjs +177 -0
- package/skills/discuss-work/SKILL.md +148 -0
- package/skills/{discuss → discuss-work}/references/discussion-continuity.md +10 -27
- package/skills/discuss-work/references/learning-action.md +36 -0
- package/skills/discuss-work/scripts/choices.mjs +101 -0
- package/skills/discuss-work/scripts/lib/choices.mjs +159 -0
- package/skills/evaluate-behavior/SKILL.md +107 -0
- package/skills/evaluate-behavior/references/authoring.md +381 -0
- package/skills/evaluate-behavior/references/coding-tasks.md +145 -0
- package/skills/evaluate-behavior/references/conversations-pipelines-judges.md +177 -0
- package/skills/evaluate-behavior/references/design-and-measurement.md +273 -0
- package/skills/evaluate-behavior/references/running-and-evidence.md +245 -0
- package/skills/evaluate-behavior/scripts/claude-guard.mjs +76 -0
- package/skills/evaluate-behavior/scripts/guard-rules.mjs +309 -0
- package/skills/evaluate-behavior/scripts/lib/agreement.mjs +90 -0
- package/skills/evaluate-behavior/scripts/lib/arms.mjs +55 -0
- package/skills/evaluate-behavior/scripts/lib/budget.mjs +87 -0
- package/skills/evaluate-behavior/scripts/lib/build-source.mjs +287 -0
- package/skills/evaluate-behavior/scripts/lib/clear.mjs +74 -0
- package/skills/evaluate-behavior/scripts/lib/container.mjs +395 -0
- package/skills/evaluate-behavior/scripts/lib/decision.mjs +418 -0
- package/skills/evaluate-behavior/scripts/lib/definitions.mjs +1072 -0
- package/skills/evaluate-behavior/scripts/lib/freeflow.mjs +103 -0
- package/skills/evaluate-behavior/scripts/lib/grade-command.mjs +591 -0
- package/skills/evaluate-behavior/scripts/lib/grade-comparison.mjs +90 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-interaction.mjs +19 -18
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-support.mjs +32 -5
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/grade-workspace.mjs +28 -12
- package/skills/evaluate-behavior/scripts/lib/grade.mjs +101 -0
- package/skills/evaluate-behavior/scripts/lib/hooks.mjs +98 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/confinement.mjs +196 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/constraints.mjs +111 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/index.mjs +48 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/isolation.mjs +74 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/launch.mjs +123 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/model-call.mjs +73 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/recorder.mjs +178 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/session.mjs +494 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/claude-code/skills.mjs +81 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/index.mjs +31 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/names.mjs +3 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/confinement.mjs +173 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/host.mjs +148 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/index.mjs +70 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/isolation.mjs +81 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/model-call.mjs +79 -0
- package/skills/evaluate-behavior/scripts/lib/hosts/pi/process.mjs +241 -0
- package/skills/{evaluate-skill/scripts/lib → evaluate-behavior/scripts/lib/hosts/pi}/rpc.mjs +144 -26
- package/skills/evaluate-behavior/scripts/lib/identity.mjs +82 -0
- package/skills/evaluate-behavior/scripts/lib/judge.mjs +688 -0
- package/skills/evaluate-behavior/scripts/lib/metrics.mjs +332 -0
- package/skills/evaluate-behavior/scripts/lib/model-call.mjs +17 -0
- package/skills/evaluate-behavior/scripts/lib/participants.mjs +616 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/process.mjs +22 -0
- package/skills/evaluate-behavior/scripts/lib/queue.mjs +54 -0
- package/skills/evaluate-behavior/scripts/lib/reference.mjs +343 -0
- package/skills/evaluate-behavior/scripts/lib/reuse.mjs +277 -0
- package/skills/evaluate-behavior/scripts/lib/review.mjs +933 -0
- package/skills/evaluate-behavior/scripts/lib/runner.mjs +732 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/sandbox.mjs +119 -14
- package/skills/evaluate-behavior/scripts/lib/scan-shell.mjs +457 -0
- package/skills/evaluate-behavior/scripts/lib/scan.mjs +479 -0
- package/skills/evaluate-behavior/scripts/lib/seal.mjs +91 -0
- package/skills/evaluate-behavior/scripts/lib/shell.mjs +560 -0
- package/skills/evaluate-behavior/scripts/lib/source-override.mjs +64 -0
- package/skills/evaluate-behavior/scripts/lib/subject-file.mjs +88 -0
- package/skills/{evaluate-skill/scripts/lib/pi.mjs → evaluate-behavior/scripts/lib/subject.mjs} +235 -265
- package/skills/evaluate-behavior/scripts/lib/task-copy.mjs +124 -0
- package/skills/evaluate-behavior/scripts/lib/trials.mjs +524 -0
- package/skills/evaluate-behavior/scripts/lib/validity.mjs +68 -0
- package/skills/evaluate-behavior/scripts/lib/view-html.mjs +454 -0
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/view.mjs +159 -18
- package/skills/{evaluate-skill → evaluate-behavior}/scripts/pi-command-tool.mjs +21 -6
- package/skills/evaluate-behavior/scripts/pi-container-route.mjs +23 -0
- package/skills/evaluate-behavior/scripts/pi-guard.mjs +77 -0
- package/skills/evaluate-behavior/scripts/pi-login-refresh.mjs +31 -0
- package/skills/evaluate-behavior/scripts/skill-eval.mjs +586 -0
- package/skills/execute-work/SKILL.md +96 -104
- package/skills/execute-work/references/code-practices.md +27 -51
- package/skills/execute-work/references/design.md +271 -0
- package/skills/execute-work/references/diagnose.md +117 -0
- package/skills/execute-work/references/migration.md +108 -0
- package/skills/execute-work/references/simplify.md +65 -0
- package/skills/execute-work/scripts/self-review.mjs +422 -0
- package/skills/execute-work/scripts/touch-map.mjs +290 -0
- package/skills/review-work/SKILL.md +89 -65
- package/skills/review-work/references/adjudication.md +94 -0
- package/skills/review-work/references/artifact.md +198 -0
- package/skills/review-work/references/evals-and-judges.md +66 -0
- package/skills/review-work/references/work.md +177 -0
- package/skills/review-work/scripts/check-brief.mjs +128 -0
- package/skills/review-work/scripts/review-packet.mjs +233 -0
- package/skills/setup-freeflow/SKILL.md +68 -17
- package/skills/track-work/SKILL.md +52 -20
- package/skills/track-work/references/handoff.md +190 -0
- package/skills/track-work/references/projects.md +51 -0
- package/skills/track-work/references/working-record-format.md +36 -18
- package/skills/track-work/scripts/lib/document.mjs +17 -13
- package/skills/track-work/scripts/lib/format.mjs +2 -0
- package/skills/track-work/scripts/lib/recover.mjs +254 -0
- package/skills/track-work/scripts/lib/registry.mjs +656 -0
- package/skills/track-work/scripts/lib/search.mjs +40 -0
- package/skills/track-work/scripts/lib/store.mjs +46 -15
- package/skills/track-work/scripts/lib/views.mjs +50 -1
- package/skills/track-work/scripts/working-record.mjs +232 -13
- package/skills/verify-work/SKILL.md +45 -37
- package/skills/verify-work/references/feature-map.md +120 -0
- package/skills/verify-work/references/runtime-evidence.md +203 -0
- package/skills/verify-work/references/test-design.md +25 -6
- package/skills/verify-work/scripts/test-changes.mjs +246 -0
- package/skills/write-docs/SKILL.md +113 -25
- package/skills/write-docs/references/documentation-shapes.md +68 -3
- package/skills/write-docs/references/spec-and-plan-shapes.md +308 -0
- package/skills/write-docs/scripts/artifact-check.mjs +148 -0
- package/skills/write-docs/scripts/docs-check.mjs +96 -6
- package/skills/write-docs/scripts/plan-lint.mjs +123 -0
- package/skills/write-skill/SKILL.md +92 -52
- package/skills/write-skill/references/activation-and-wording.md +143 -0
- package/skills/write-skill/references/placement-and-dependencies.md +154 -0
- package/skills/write-skill/scripts/lib/compare.mjs +701 -0
- package/skills/write-skill/scripts/lib/skill-author.mjs +197 -3
- package/skills/write-skill/scripts/skill-author.mjs +51 -8
- package/capabilities/cognitive-routing/references/both-mode.md +0 -36
- package/capabilities/cognitive-routing/references/executor-mode.md +0 -48
- package/capabilities/cognitive-routing/references/helper-mode.md +0 -47
- package/plugin.json +0 -33
- package/skills/action-selection/SKILL.md +0 -92
- package/skills/action-selection/references/trajectory-stalls.md +0 -73
- package/skills/bypass/SKILL.md +0 -153
- package/skills/commit-work/SKILL.md +0 -93
- package/skills/commit-work/references/git-state-edges.md +0 -94
- package/skills/commit-work/references/simple-push.md +0 -94
- package/skills/commit-work/references/staging-decisions.md +0 -106
- package/skills/decision-gate/SKILL.md +0 -87
- package/skills/design-for-depth/SKILL.md +0 -108
- package/skills/design-for-depth/references/design-pressure-signals.md +0 -84
- package/skills/design-for-depth/references/implementation-decisions.md +0 -76
- package/skills/design-for-depth/references/interface-design-loop.md +0 -105
- package/skills/design-for-depth/references/module-and-dependency-design.md +0 -117
- package/skills/design-for-depth/references/state-and-failure-boundaries.md +0 -125
- package/skills/diagnose-failure/SKILL.md +0 -140
- package/skills/diagnose-failure/references/diagnostic-loop-catalog.md +0 -50
- package/skills/diagnose-failure/references/flaky-and-performance-diagnosis.md +0 -51
- package/skills/discuss/SKILL.md +0 -140
- package/skills/evaluate-skill/SKILL.md +0 -188
- package/skills/evaluate-skill/references/evaluation-definition-schema.md +0 -415
- package/skills/evaluate-skill/references/evaluation-design.md +0 -112
- package/skills/evaluate-skill/references/execution-and-evidence.md +0 -111
- package/skills/evaluate-skill/references/review-and-revision.md +0 -94
- package/skills/evaluate-skill/scripts/lib/definitions.mjs +0 -448
- package/skills/evaluate-skill/scripts/lib/grade-comparison.mjs +0 -60
- package/skills/evaluate-skill/scripts/lib/grade.mjs +0 -64
- package/skills/evaluate-skill/scripts/lib/review.mjs +0 -196
- package/skills/evaluate-skill/scripts/lib/runner.mjs +0 -405
- package/skills/evaluate-skill/scripts/lib/trials.mjs +0 -103
- package/skills/evaluate-skill/scripts/pi-guard.mjs +0 -181
- package/skills/evaluate-skill/scripts/skill-eval.mjs +0 -182
- package/skills/execute-work/references/execute-work-edges.md +0 -78
- package/skills/finish-branch/SKILL.md +0 -117
- package/skills/finish-branch/references/integration-options.md +0 -80
- package/skills/handoff/SKILL.md +0 -82
- package/skills/handoff/references/handoff-templates.md +0 -150
- package/skills/launch-work/SKILL.md +0 -116
- package/skills/launch-work/references/launch-readiness.md +0 -85
- package/skills/migration-work/SKILL.md +0 -115
- package/skills/migration-work/references/migration-lifecycle.md +0 -82
- package/skills/release-work/SKILL.md +0 -118
- package/skills/release-work/references/release-evidence.md +0 -78
- package/skills/review-artifact/SKILL.md +0 -143
- package/skills/review-artifact/references/adjudicate-artifact-review.md +0 -101
- package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +0 -159
- package/skills/review-work/references/adjudicate-work-review.md +0 -97
- package/skills/review-work/references/independent-work-reviewer-contract.md +0 -139
- package/skills/review-work/references/security-risk-lens.md +0 -67
- package/skills/setup-freeflow/references/activation-contract.md +0 -103
- package/skills/setup-freeflow/references/host-setup.md +0 -67
- package/skills/simplify-code/SKILL.md +0 -85
- package/skills/simplify-code/references/simplification-patterns.md +0 -70
- package/skills/verify-work/references/browser-runtime-evidence.md +0 -51
- package/skills/verify-work/references/integration-evidence.md +0 -49
- package/skills/verify-work/references/performance-evidence.md +0 -59
- package/skills/workflow/SKILL.md +0 -171
- package/skills/workflow/references/domain-skill-composition.md +0 -45
- package/skills/write-plan/SKILL.md +0 -92
- package/skills/write-plan/references/plan-shapes.md +0 -156
- package/skills/write-skill/references/activation-boundaries.md +0 -106
- package/skills/write-skill/references/agent-first-instructions.md +0 -133
- package/skills/write-skill/references/progressive-disclosure.md +0 -129
- package/skills/write-skill/references/skill-dependency-graphs.md +0 -192
- package/skills/write-spec/SKILL.md +0 -85
- package/skills/write-spec/references/artifact-standards.md +0 -86
- package/skills/write-spec/references/decision-records.md +0 -80
- package/skills/write-spec/references/spec-shapes.md +0 -143
- /package/skills/{evaluate-skill → evaluate-behavior}/scripts/lib/evidence.mjs +0 -0
- /package/skills/{evaluate-skill → evaluate-behavior}/scripts/pi-observer.mjs +0 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { freeflowHome, Registry } from "./registry.js";
|
|
7
|
+
/**
|
|
8
|
+
* Task memory: which Working Records a session works on. The user attaches records (the model may propose one); one
|
|
9
|
+
* attached record is the focus. State is a fold over branch entries, so it follows /tree navigation and is copied on
|
|
10
|
+
* fork; it is rebuilt at session start and navigation and otherwise kept in memory. A record is either registered
|
|
11
|
+
* (a task or child in a project store, with an ID and one owner session; see registry.ts) or unregistered (a record
|
|
12
|
+
* attached by path where Track Work keeps it). Records are read through Track Work's own library, so a record stays
|
|
13
|
+
* valid for the script skill on other hosts.
|
|
14
|
+
*/
|
|
15
|
+
export const TASK_ENTRY = "freeflow-task-memory-v1";
|
|
16
|
+
export const SNAPSHOT_TYPE = "freeflow-task-memory-snapshot";
|
|
17
|
+
/** How many of a project's other tasks a resume view indexes; the rest are counted and reached by search or list. */
|
|
18
|
+
export const PROJECT_INDEX_LIMIT = 15;
|
|
19
|
+
const TASK_ID = /^[a-z0-9-]+\/task-\d+$/;
|
|
20
|
+
function section(text, heading) {
|
|
21
|
+
const match = new RegExp(`^### ${heading}\\n\\n([\\s\\S]*?)(?=\\n#{2,3} |$)`, "m").exec(text);
|
|
22
|
+
return match?.[1].trim();
|
|
23
|
+
}
|
|
24
|
+
/** One line about a record: its task state, Current Slice and Next useful action. */
|
|
25
|
+
export function briefOf(text) {
|
|
26
|
+
const slice = section(text, "Current Slice") ?? "";
|
|
27
|
+
return {
|
|
28
|
+
state: /^State:\s*(\S+)/m.exec(text)?.[1] ?? "unknown",
|
|
29
|
+
goal: (section(text, "Goal") ?? "").replace(/^- /, "").split("\n")[0],
|
|
30
|
+
slice: /^#### (S-\d+ — .+)$/m.exec(slice)?.[1] ?? "None",
|
|
31
|
+
next: (section(text, "Next useful action") ?? "").replace(/^- /, "").split("\n")[0],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const RECORD_PATH = /(^|[\\/])\.freeflow[\\/]tasks[\\/][^\\/]+[\\/]record\.md$/;
|
|
35
|
+
// A project store's record: <store>/tasks/<task>/record.md, where <store>/store.json marks the store.
|
|
36
|
+
const STORE_RECORD = /[\\/]tasks[\\/]task-\d+-[^\\/]+[\\/]record\.md$/;
|
|
37
|
+
// A Track Work command that writes a record: lifecycle groups, task set-state, and task import.
|
|
38
|
+
const RECORD_WRITE =
|
|
39
|
+
/working-record\.mjs["']?\s+(?:(?:slice|checkpoint|decision)\s+[a-z-]+|task\s+(?:set-state|import))(?![a-z-])/;
|
|
40
|
+
// A Track Work command that changes the registry: the task_memory tool asks the user for each of these on Pi.
|
|
41
|
+
const REGISTRY_CHANGE =
|
|
42
|
+
/working-record\.mjs["']?\s+(?:task\s+(?:create|fanout|remove)|project\s+(?:new|link|remove))(?![a-z-])/;
|
|
43
|
+
const ENVIRONMENT_CHANGE = /working-record\.mjs["']?\s+task\s+env(?![a-z-])[^;&|\n]*--(?:primary|add-dir|remove-dir)/;
|
|
44
|
+
// Markers recordTargets returns for a write whose record is named by task ID, or cannot be read from the command.
|
|
45
|
+
export const ID_TARGET = "id:";
|
|
46
|
+
export const UNRESOLVED_TARGET = "unresolved:";
|
|
47
|
+
export function isRecordPath(path) {
|
|
48
|
+
return (
|
|
49
|
+
RECORD_PATH.test(path) ||
|
|
50
|
+
(STORE_RECORD.test(path) && existsSync(join(dirname(dirname(dirname(path))), "store.json")))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
/** The session's task state on a branch: the attach, detach, focus, hold and confirm entries in order. */
|
|
54
|
+
export function foldState(branch) {
|
|
55
|
+
const attached = [];
|
|
56
|
+
const held = new Set();
|
|
57
|
+
let focus;
|
|
58
|
+
for (const entry of branch ?? []) {
|
|
59
|
+
if (entry?.type !== "custom" || entry.customType !== TASK_ENTRY) continue;
|
|
60
|
+
const { op, record } = entry.data ?? {};
|
|
61
|
+
if (typeof record !== "string") continue;
|
|
62
|
+
const index = attached.indexOf(record);
|
|
63
|
+
if (op === "attach") {
|
|
64
|
+
if (index < 0) attached.push(record);
|
|
65
|
+
held.delete(record);
|
|
66
|
+
focus = record;
|
|
67
|
+
} else if (op === "detach" && index >= 0) {
|
|
68
|
+
attached.splice(index, 1);
|
|
69
|
+
held.delete(record);
|
|
70
|
+
if (focus === record) focus = undefined;
|
|
71
|
+
} else if (op === "focus" && index >= 0) focus = record;
|
|
72
|
+
else if (op === "hold" && index >= 0) held.add(record);
|
|
73
|
+
else if (op === "confirm") held.delete(record);
|
|
74
|
+
}
|
|
75
|
+
return { attached, held: [...held], focus };
|
|
76
|
+
}
|
|
77
|
+
function sameState(a, b) {
|
|
78
|
+
return (
|
|
79
|
+
a.focus === b.focus && a.attached.join("\n") === b.attached.join("\n") && a.held.join("\n") === b.held.join("\n")
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
function option(command, name) {
|
|
83
|
+
const match = new RegExp(`${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|(\\S+))`).exec(command);
|
|
84
|
+
return match ? (match[1] ?? match[2] ?? match[3]) : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** A path with its symlinks resolved; for a path that does not exist yet, its nearest existing ancestor's. */
|
|
87
|
+
export function canonicalPath(path) {
|
|
88
|
+
const missing = [];
|
|
89
|
+
for (let current = resolve(path); ;) {
|
|
90
|
+
try {
|
|
91
|
+
return join(realpathSync(current), ...missing);
|
|
92
|
+
} catch {
|
|
93
|
+
const parent = dirname(current);
|
|
94
|
+
if (parent === current) return resolve(path);
|
|
95
|
+
missing.unshift(basename(current));
|
|
96
|
+
current = parent;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The Working Record files a tool call would write, resolved to absolute paths. A path to an attached record by another
|
|
102
|
+
* route (the project link, a symlinked home) is returned as the attached path, so the gate sees one file as one record.
|
|
103
|
+
*/
|
|
104
|
+
export function recordTargets(toolName, input, cwd, attached) {
|
|
105
|
+
const asAttached = (path) => {
|
|
106
|
+
if (attached.includes(path)) return path;
|
|
107
|
+
const real = canonicalPath(path);
|
|
108
|
+
return attached.find((item) => canonicalPath(item) === real) ?? path;
|
|
109
|
+
};
|
|
110
|
+
// As Pi's tools read a path: a leading @ dropped, ~ for the home directory.
|
|
111
|
+
const expand = (path) => {
|
|
112
|
+
const bare = path.startsWith("@") ? path.slice(1) : path;
|
|
113
|
+
return bare === "~" ? homedir() : bare.startsWith("~/") ? join(homedir(), bare.slice(2)) : bare;
|
|
114
|
+
};
|
|
115
|
+
const absolute = (path, base = cwd) => {
|
|
116
|
+
const bare = expand(path);
|
|
117
|
+
return asAttached(isAbsolute(bare) ? resolve(bare) : resolve(base, bare));
|
|
118
|
+
};
|
|
119
|
+
const isTarget = (path) => isRecordPath(path) || attached.includes(path);
|
|
120
|
+
if ((toolName === "edit" || toolName === "write") && typeof input?.path === "string") {
|
|
121
|
+
const path = absolute(input.path);
|
|
122
|
+
return isTarget(path) ? [path] : [];
|
|
123
|
+
}
|
|
124
|
+
if (toolName === "apply_patch" && typeof input?.input === "string") {
|
|
125
|
+
const paths = [...input.input.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((match) =>
|
|
126
|
+
absolute(match[1].trim()),
|
|
127
|
+
);
|
|
128
|
+
return paths.filter(isTarget);
|
|
129
|
+
}
|
|
130
|
+
if (toolName === "bash" && typeof input?.command === "string") {
|
|
131
|
+
const targets = [];
|
|
132
|
+
// Each command segment that writes a record, after expanding variables assigned earlier in the same command, so
|
|
133
|
+
// `S=<script>; R=<record>; node $S decision add --record "$R"` is seen like the literal form.
|
|
134
|
+
for (const segment of expandAssignments(input.command).split(/;|&&|\|\||\||\n/)) {
|
|
135
|
+
// A help request writes nothing; Track Work tells agents to read it before preparing input.
|
|
136
|
+
if (!RECORD_WRITE.test(segment) || /(^|\s)--help(\s|$)/.test(segment)) continue;
|
|
137
|
+
const record = option(segment, "--record");
|
|
138
|
+
if (!record || /[$`*?]/.test(record)) targets.push(`${UNRESOLVED_TARGET}${record ?? "(no --record)"}`);
|
|
139
|
+
else if (/^[a-z0-9-]+\/task-\d+$/.test(record)) targets.push(`${ID_TARGET}${record}`);
|
|
140
|
+
else {
|
|
141
|
+
const root = option(segment, "--root");
|
|
142
|
+
targets.push(absolute(record, root && !/[$`]/.test(root) ? absolute(root) : cwd));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return targets;
|
|
146
|
+
}
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Replace $NAME and ${NAME} with the value last assigned as NAME=value before that point in the same command, as the
|
|
151
|
+
* shell would; others stay as they are.
|
|
152
|
+
*/
|
|
153
|
+
function expandAssignments(command) {
|
|
154
|
+
const assignments = [
|
|
155
|
+
...command.matchAll(/(?:^|[\s;&|(])([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]*))/g),
|
|
156
|
+
].map((match) => ({
|
|
157
|
+
name: match[1],
|
|
158
|
+
value: match[2] ?? match[3] ?? match[4] ?? "",
|
|
159
|
+
end: match.index + match[0].length,
|
|
160
|
+
}));
|
|
161
|
+
return command.replace(
|
|
162
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
163
|
+
(whole, braced, bare, offset) => {
|
|
164
|
+
let value;
|
|
165
|
+
for (const assignment of assignments)
|
|
166
|
+
if (assignment.name === (braced ?? bare) && assignment.end <= offset) value = assignment.value;
|
|
167
|
+
return value ?? whole;
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
export class TaskMemory {
|
|
172
|
+
host;
|
|
173
|
+
current = { attached: [], held: [] };
|
|
174
|
+
/** This branch's task entries, in order; a write adds one and folds again. */
|
|
175
|
+
entries = [];
|
|
176
|
+
names = new Map();
|
|
177
|
+
/** Registered records' IDs by path, for the Runtime State. */
|
|
178
|
+
ids = new Map();
|
|
179
|
+
lib;
|
|
180
|
+
registry;
|
|
181
|
+
constructor(host) {
|
|
182
|
+
this.host = host;
|
|
183
|
+
this.registry = host.registry ?? new Registry(host.packageRoot);
|
|
184
|
+
}
|
|
185
|
+
state() {
|
|
186
|
+
return this.current;
|
|
187
|
+
}
|
|
188
|
+
/** Rebuild from the branch. Returns whether the state changed. */
|
|
189
|
+
rebuild(branch) {
|
|
190
|
+
this.entries = (branch ?? []).filter((entry) => entry?.type === "custom" && entry.customType === TASK_ENTRY);
|
|
191
|
+
const next = foldState(this.entries);
|
|
192
|
+
const changed = !sameState(this.current, next);
|
|
193
|
+
this.current = next;
|
|
194
|
+
return changed;
|
|
195
|
+
}
|
|
196
|
+
/** Learn the attached records' names and IDs, for the Runtime State. */
|
|
197
|
+
async load() {
|
|
198
|
+
for (const record of this.current.attached) {
|
|
199
|
+
await this.read(record);
|
|
200
|
+
const found = await this.registered(record);
|
|
201
|
+
if (found) this.ids.set(record, found.id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
write(op, record) {
|
|
205
|
+
const data = { op, record, at: new Date().toISOString() };
|
|
206
|
+
this.host.append(data);
|
|
207
|
+
this.entries.push({ type: "custom", customType: TASK_ENTRY, data });
|
|
208
|
+
this.current = foldState(this.entries);
|
|
209
|
+
}
|
|
210
|
+
script() {
|
|
211
|
+
return join(this.host.packageRoot, "skills", "track-work", "scripts", "working-record.mjs");
|
|
212
|
+
}
|
|
213
|
+
async views() {
|
|
214
|
+
this.lib ??= import(pathToFileURL(join(this.host.packageRoot, "skills/track-work/scripts/lib/views.mjs")).href);
|
|
215
|
+
return this.lib;
|
|
216
|
+
}
|
|
217
|
+
name(record) {
|
|
218
|
+
return this.names.get(record) ?? basename(dirname(record));
|
|
219
|
+
}
|
|
220
|
+
label(record) {
|
|
221
|
+
const id = this.ids.get(record);
|
|
222
|
+
return id ? `${this.name(record)} [${id}]` : this.name(record);
|
|
223
|
+
}
|
|
224
|
+
async read(record) {
|
|
225
|
+
try {
|
|
226
|
+
const text = await readFile(record, "utf8");
|
|
227
|
+
const heading = /^# Working Record:\s*(.+?)\s*$/m.exec(text);
|
|
228
|
+
if (heading) this.names.set(record, heading[1]);
|
|
229
|
+
return { text };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { error: error?.code === "ENOENT" ? "the file does not exist" : String(error?.message ?? error) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** The registry entry for a record path, or undefined when unregistered or the registry is unreadable. */
|
|
235
|
+
async registered(record) {
|
|
236
|
+
try {
|
|
237
|
+
return await this.registry.byRecord(record);
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** The focused record's resume view, labelled as a snapshot, with its parent or children for a registered task. */
|
|
243
|
+
async snapshot(record) {
|
|
244
|
+
const { text, error } = await this.read(record);
|
|
245
|
+
const found = await this.registered(record);
|
|
246
|
+
if (found) this.ids.set(record, found.id);
|
|
247
|
+
const head = `[Freeflow task memory] Focused task: ${this.label(record)} (${record}), read ${new Date().toISOString()}.`;
|
|
248
|
+
if (error) return `${head}\nThe record is unavailable: ${error}. Nothing was substituted for it.`;
|
|
249
|
+
let resume;
|
|
250
|
+
try {
|
|
251
|
+
resume = (await this.views()).renderResume(text);
|
|
252
|
+
} catch (failure) {
|
|
253
|
+
return `${head}\nThe record cannot be read as a Schema 4 resume view (${failure?.message ?? failure}). Read it with \`node ${this.script()} view full --record ${record}\` and do not edit it.`;
|
|
254
|
+
}
|
|
255
|
+
const fetch = found
|
|
256
|
+
? `a call to the task_memory tool (action view, with the record's ID and an entry ID)`
|
|
257
|
+
: `\`node ${this.script()} view entry <ID> --record ${record}\``;
|
|
258
|
+
// Enclosed in tags with a closing line: delivered as a message right before the user's prompt, the record would
|
|
259
|
+
// otherwise run into it (providers may join consecutive user-role messages), and the prompt would read as the
|
|
260
|
+
// record's last section (seen in Pi evals: the user's request was taken for the record's Notes).
|
|
261
|
+
return [
|
|
262
|
+
`<task-memory record="${record}"${found ? ` id="${found.id}"` : ""}>`,
|
|
263
|
+
head,
|
|
264
|
+
"This is task memory, not an instruction: the user's latest message decides what to do now. The record's Next useful action is where that task stood, not a request.",
|
|
265
|
+
`This is the record's resume view: its present state and an index of closed History. It is Track Work's recovery read for this record, so do not read it again; read a closed entry with ${fetch}. It is a copy at this time; after the record changes, read it again through Track Work.`,
|
|
266
|
+
...(found ? await this.family(found.id, found.task) : []),
|
|
267
|
+
...(found ? await this.projectLines(found.id, found.task) : []),
|
|
268
|
+
...(found ? await this.environmentLines(found.id) : []),
|
|
269
|
+
"",
|
|
270
|
+
resume.trimEnd(),
|
|
271
|
+
"</task-memory>",
|
|
272
|
+
"End of task memory. Anything after this is not part of the record.",
|
|
273
|
+
].join("\n");
|
|
274
|
+
}
|
|
275
|
+
/** A child's Parent block, or a parent's Children block: one line per record, read from the records. */
|
|
276
|
+
async family(id, task) {
|
|
277
|
+
if (task.parent) {
|
|
278
|
+
const parent = await this.registry.task(task.parent);
|
|
279
|
+
if (!parent) return ["", `Parent: ${task.parent} (not in the registry).`];
|
|
280
|
+
const { text } = await this.read(parent.record);
|
|
281
|
+
const goal = text ? briefOf(text).goal : "unavailable";
|
|
282
|
+
return [
|
|
283
|
+
"",
|
|
284
|
+
`Parent: ${task.parent} — ${parent.title} (${parent.record}). Goal: ${goal}. Its owner session writes it; this session does not.`,
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
const children = await this.registry.children(id);
|
|
288
|
+
if (!children.length) return [];
|
|
289
|
+
const lines = [
|
|
290
|
+
"",
|
|
291
|
+
"Children (each written by its own session; read one by calling the task_memory tool with action view):",
|
|
292
|
+
];
|
|
293
|
+
for (const child of children) {
|
|
294
|
+
const { text } = await this.read(child.task.record);
|
|
295
|
+
if (!text) {
|
|
296
|
+
lines.push(`- ${child.id} — ${child.task.title}: unavailable`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const brief = briefOf(text);
|
|
300
|
+
lines.push(
|
|
301
|
+
`- ${child.id} — ${child.task.title}: ${brief.state}; Current Slice: ${brief.slice}; next: ${brief.next}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return lines;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* A registered task's index of its project's other tasks (not itself, its parent or its children, which the family
|
|
308
|
+
* block already shows): the most recently changed first, one line each, so the agent can tell which to read.
|
|
309
|
+
*/
|
|
310
|
+
async projectLines(id, task) {
|
|
311
|
+
try {
|
|
312
|
+
const data = await this.registry.read();
|
|
313
|
+
const project = data.projects[task.project];
|
|
314
|
+
const others = Object.entries(data.tasks).filter(
|
|
315
|
+
([other, entry]) =>
|
|
316
|
+
other !== id && other !== task.parent && entry.parent !== id && entry.project === task.project,
|
|
317
|
+
);
|
|
318
|
+
if (!others.length) return [];
|
|
319
|
+
const changed = await Promise.all(
|
|
320
|
+
others.map(async ([other, entry]) => ({
|
|
321
|
+
id: other,
|
|
322
|
+
entry,
|
|
323
|
+
at: await stat(entry.record)
|
|
324
|
+
.then((info) => info.mtimeMs)
|
|
325
|
+
.catch(() => 0),
|
|
326
|
+
})),
|
|
327
|
+
);
|
|
328
|
+
changed.sort((a, b) => b.at - a.at);
|
|
329
|
+
const lines = [
|
|
330
|
+
"",
|
|
331
|
+
`Project ${project?.name ?? task.project}, other tasks (most recently changed first; read one by calling the task_memory tool with action view, or search them with action search and scope project):`,
|
|
332
|
+
];
|
|
333
|
+
for (const item of changed.slice(0, PROJECT_INDEX_LIMIT)) {
|
|
334
|
+
const { text } = await this.read(item.entry.record);
|
|
335
|
+
const state = text ? briefOf(text).state : "unavailable";
|
|
336
|
+
lines.push(
|
|
337
|
+
`- ${item.id} ${state} — ${item.entry.title}${item.entry.parent ? ` (child of ${item.entry.parent})` : ""}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (changed.length > PROJECT_INDEX_LIMIT)
|
|
341
|
+
lines.push(
|
|
342
|
+
`- … ${changed.length - PROJECT_INDEX_LIMIT} more: list them with the task_memory tool's list action, or search the project.`,
|
|
343
|
+
);
|
|
344
|
+
return lines;
|
|
345
|
+
} catch {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** A record named by task ID, path, or task directory. */
|
|
350
|
+
async locate(record, cwd) {
|
|
351
|
+
if (typeof record !== "string" || !record.trim()) return { error: "Name the task's ID or its record.md." };
|
|
352
|
+
const name = record.trim();
|
|
353
|
+
if (TASK_ID.test(name)) {
|
|
354
|
+
let task;
|
|
355
|
+
try {
|
|
356
|
+
task = await this.registry.task(name);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return { error: error?.message ?? String(error) };
|
|
359
|
+
}
|
|
360
|
+
if (!task) return { error: `No task ${name} in the registry.` };
|
|
361
|
+
await this.read(task.record);
|
|
362
|
+
this.ids.set(task.record, name);
|
|
363
|
+
return { path: task.record, id: name };
|
|
364
|
+
}
|
|
365
|
+
let path = isAbsolute(name) ? resolve(name) : resolve(cwd, name);
|
|
366
|
+
try {
|
|
367
|
+
if ((await stat(path)).isDirectory()) path = join(path, "record.md");
|
|
368
|
+
if (!(await stat(path)).isFile()) return { error: `${path} is not a file.` };
|
|
369
|
+
} catch {
|
|
370
|
+
return { error: `${path} does not exist.` };
|
|
371
|
+
}
|
|
372
|
+
if (basename(path) !== "record.md") return { error: `${path} is not a Working Record (record.md).` };
|
|
373
|
+
// A registered record named through the project link is the registry's path, as a task ID would give.
|
|
374
|
+
const found = await this.registered(path);
|
|
375
|
+
if (found) path = found.task.record;
|
|
376
|
+
await this.read(path);
|
|
377
|
+
if (found) this.ids.set(path, found.id);
|
|
378
|
+
return { path, id: found?.id };
|
|
379
|
+
}
|
|
380
|
+
/** Take ownership of a registered record, asking the user before taking it from another session. */
|
|
381
|
+
async own(id, takeover) {
|
|
382
|
+
try {
|
|
383
|
+
const result = await this.registry.own(id, this.host.owner(), takeover);
|
|
384
|
+
if (result.owned === true) return undefined;
|
|
385
|
+
const { by } = result;
|
|
386
|
+
const yes = await this.host.confirm(
|
|
387
|
+
`Take over ${id}?`,
|
|
388
|
+
`${id} is owned by session ${by.session}${by.file ? ` (${by.file})` : ""} since ${by.since}. Take it over? That session's writes to it will then be refused.`,
|
|
389
|
+
);
|
|
390
|
+
if (!yes) return `${id} is owned by session ${by.session}; it was not taken over, so it is not attached.`;
|
|
391
|
+
await this.registry.own(id, this.host.owner(), true);
|
|
392
|
+
return undefined;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return String(error?.message ?? error);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async attach(record, cwd) {
|
|
398
|
+
const { path, id, error } = await this.locate(record, cwd);
|
|
399
|
+
if (!path) return { ok: false, text: error };
|
|
400
|
+
if (id) {
|
|
401
|
+
const refused = await this.own(id, false);
|
|
402
|
+
if (refused) return { ok: false, text: refused };
|
|
403
|
+
}
|
|
404
|
+
this.write("attach", path);
|
|
405
|
+
return { ok: true, text: await this.snapshot(path), snapshot: path };
|
|
406
|
+
}
|
|
407
|
+
async attachedMatch(record, cwd) {
|
|
408
|
+
if (typeof record !== "string") return undefined;
|
|
409
|
+
const direct = resolve(cwd, record);
|
|
410
|
+
const match = this.current.attached.find((item) => item === direct || item === join(direct, "record.md"));
|
|
411
|
+
if (match) return match;
|
|
412
|
+
// The same record through the project link.
|
|
413
|
+
const real = canonicalPath(direct.endsWith("record.md") ? direct : join(direct, "record.md"));
|
|
414
|
+
const linked = this.current.attached.find((item) => canonicalPath(item) === real);
|
|
415
|
+
if (linked) return linked;
|
|
416
|
+
if (TASK_ID.test(record.trim())) {
|
|
417
|
+
const { path } = await this.locate(record, cwd);
|
|
418
|
+
return path && this.current.attached.includes(path) ? path : undefined;
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
async focus(record, cwd) {
|
|
423
|
+
const match = await this.attachedMatch(record, cwd);
|
|
424
|
+
if (!match) return { ok: false, text: `${record} is not attached. ${this.attachedText()}` };
|
|
425
|
+
if (this.current.focus !== match) this.write("focus", match);
|
|
426
|
+
return { ok: true, text: await this.snapshot(match), snapshot: match };
|
|
427
|
+
}
|
|
428
|
+
async detach(record, cwd) {
|
|
429
|
+
const match = await this.attachedMatch(record, cwd);
|
|
430
|
+
if (!match) return { ok: false, text: `${record} is not attached. ${this.attachedText()}` };
|
|
431
|
+
this.write("detach", match);
|
|
432
|
+
const id = this.ids.get(match);
|
|
433
|
+
if (id) await this.registry.release(id, this.host.owner().session).catch(() => {});
|
|
434
|
+
return { ok: true, text: `Detached ${this.label(match)} (${match}). ${this.attachedText()}` };
|
|
435
|
+
}
|
|
436
|
+
/** Release held records, one or all, taking ownership of registered ones: the user's command is the confirmation. */
|
|
437
|
+
async confirm(record, cwd) {
|
|
438
|
+
const named = typeof record === "string" && record.trim();
|
|
439
|
+
const path = named ? await this.attachedMatch(record, cwd) : undefined;
|
|
440
|
+
if (named && !path) return { ok: false, text: `${record} is not attached to this session. ${this.attachedText()}` };
|
|
441
|
+
const targets = this.current.held.filter((item) => !path || item === path);
|
|
442
|
+
if (!targets.length) return { ok: false, text: "No held record matches." };
|
|
443
|
+
for (const item of targets) {
|
|
444
|
+
const id = this.ids.get(item) ?? (await this.registered(item))?.id;
|
|
445
|
+
if (id) {
|
|
446
|
+
const refused = await this.own(id, true);
|
|
447
|
+
if (refused) return { ok: false, text: refused };
|
|
448
|
+
}
|
|
449
|
+
this.write("confirm", item);
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, text: `Writable again: ${targets.map((item) => this.label(item)).join(", ")}.` };
|
|
452
|
+
}
|
|
453
|
+
/** After a fork or clone: inherited records stay listed and readable but not writable until confirmed. */
|
|
454
|
+
holdInherited() {
|
|
455
|
+
const inherited = this.current.attached.filter((item) => !this.current.held.includes(item));
|
|
456
|
+
for (const item of inherited) this.write("hold", item);
|
|
457
|
+
return inherited;
|
|
458
|
+
}
|
|
459
|
+
/** Create a task in this repository's project. It is not attached; the agent proposes that next. */
|
|
460
|
+
async create(title, goal, cwd) {
|
|
461
|
+
if (typeof title !== "string" || !title.trim()) return { ok: false, text: "Give the task a title." };
|
|
462
|
+
try {
|
|
463
|
+
const project = await this.registry.projectFor(cwd);
|
|
464
|
+
if (!project)
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
text: "This repository is not linked to a project. The user creates one with /freeflow project new <name>.",
|
|
468
|
+
};
|
|
469
|
+
const created = await this.registry.createTask(
|
|
470
|
+
project.slug,
|
|
471
|
+
title.trim(),
|
|
472
|
+
String(goal || title).trim(),
|
|
473
|
+
undefined,
|
|
474
|
+
await this.registry.repoRoot(cwd),
|
|
475
|
+
);
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
id: created.id,
|
|
479
|
+
headline: `Created ${created.id} — ${title.trim()}`,
|
|
480
|
+
text: `Created ${created.id} (${created.record}). It is not attached; propose attaching it with task_memory.`,
|
|
481
|
+
};
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** Where a registered task's work happens, and a warning when this session runs elsewhere. */
|
|
487
|
+
async environmentLines(id) {
|
|
488
|
+
try {
|
|
489
|
+
const env = await this.registry.environment(id);
|
|
490
|
+
if (!env.primary) return [];
|
|
491
|
+
const facts =
|
|
492
|
+
env.exists === false
|
|
493
|
+
? ["missing"]
|
|
494
|
+
: [env.worktree ? "Git worktree" : "", env.branch ? `branch ${env.branch}` : ""];
|
|
495
|
+
const state = facts.filter(Boolean).join(", ");
|
|
496
|
+
const lines = [
|
|
497
|
+
"",
|
|
498
|
+
`Environment: ${env.primary}${state ? ` (${state})` : ""}${env.directories.length ? `; also ${env.directories.join(", ")}` : ""}.`,
|
|
499
|
+
];
|
|
500
|
+
const here = resolve(this.host.cwd());
|
|
501
|
+
if (here !== env.primary && !here.startsWith(env.primary + sep))
|
|
502
|
+
lines.push(
|
|
503
|
+
`This session runs in ${here}, outside this task's directory; the task's effects belong in ${env.primary}.`,
|
|
504
|
+
);
|
|
505
|
+
return lines;
|
|
506
|
+
} catch {
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The Track Work command that records a delegation in the parent's record, ready to run: the split already happened
|
|
512
|
+
* (the user confirmed it), so it is a retroactive Slice. Adjust its wording, not its shape.
|
|
513
|
+
*/
|
|
514
|
+
delegation(parent, child, title, result) {
|
|
515
|
+
// The user's calendar day, as Track Work's own Recorded date uses.
|
|
516
|
+
const now = new Date();
|
|
517
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
518
|
+
return [
|
|
519
|
+
`Record the split in ${parent}'s record now, with this Track Work command (edit the wording if needed; then update ${parent}'s Next useful action if this session should not do that work):`,
|
|
520
|
+
"",
|
|
521
|
+
// Single quotes, so a title with $(…) or backticks is text, not a command the shell runs.
|
|
522
|
+
`node ${this.script()} slice record --record ${parent} --title '${`Split off: ${title}`.replaceAll("'", "'\\''")}' --state completed --input - <<'EOF'`,
|
|
523
|
+
`- Occurred: ${today}`,
|
|
524
|
+
`- Intended result: ${title} continues in its own session as ${child}.`,
|
|
525
|
+
`- Authority source: The user confirmed the split in Freeflow's dialog.`,
|
|
526
|
+
`- Result: ${result}`,
|
|
527
|
+
`- Evidence and limits: The split's tool result; ${child}'s session has not started.`,
|
|
528
|
+
`- Task effect: ${title} is done in ${child}'s session; this session reads its record when the result matters.`,
|
|
529
|
+
"EOF",
|
|
530
|
+
].join("\n");
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Fan the focused task out into a child with its own worktree, for a parallel session the user starts there. Only the
|
|
534
|
+
* focused task's owner may.
|
|
535
|
+
*/
|
|
536
|
+
async fanout(title, goal, parent) {
|
|
537
|
+
if (typeof title !== "string" || !title.trim()) return { ok: false, text: "Give the child a title." };
|
|
538
|
+
try {
|
|
539
|
+
let parentId = parent;
|
|
540
|
+
if (!parentId) {
|
|
541
|
+
const focus = this.current.focus;
|
|
542
|
+
const found = focus ? await this.registered(focus) : undefined;
|
|
543
|
+
if (!found) return { ok: false, text: "The focus is not a registered task, so it cannot fan out." };
|
|
544
|
+
if (found.task.owner?.session !== this.host.owner().session)
|
|
545
|
+
return { ok: false, text: `Only the owner of ${found.id} fans it out.` };
|
|
546
|
+
parentId = found.id;
|
|
547
|
+
}
|
|
548
|
+
const result = await this.registry.fanout(parentId, title.trim(), String(goal || title).trim(), {
|
|
549
|
+
cwd: this.host.cwd(),
|
|
550
|
+
});
|
|
551
|
+
if (result.error) return { ok: false, text: result.error };
|
|
552
|
+
const command = await this.registry.startCommand(result.path, "pi");
|
|
553
|
+
return {
|
|
554
|
+
ok: true,
|
|
555
|
+
id: result.id,
|
|
556
|
+
command,
|
|
557
|
+
headline: `Child ${result.id} ready in ${result.path}`,
|
|
558
|
+
text: `Created child ${result.id} of ${parentId} with a worktree at ${result.path} on branch ${result.branch}${result.copiedConfig ? " (personal Freeflow config copied)" : ""}. To start it, the user opens a new terminal and runs:\n\n ${command}\n\nShow the user that command exactly. Freeflow in that session offers to attach ${result.id}.\n\n${this.delegation(parentId, result.id, title.trim(), `Created ${result.id} with a worktree at ${result.path} on branch ${result.branch}.`)}`,
|
|
559
|
+
};
|
|
560
|
+
} catch (error) {
|
|
561
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** The task this session's directory belongs to, when nothing is attached and the user has not declined it here. */
|
|
565
|
+
async suggestion(branch) {
|
|
566
|
+
if (!this.host.effective() || this.current.attached.length) return undefined;
|
|
567
|
+
try {
|
|
568
|
+
const declined = new Set(
|
|
569
|
+
branch
|
|
570
|
+
.filter(
|
|
571
|
+
(entry) => entry?.type === "custom" && entry.customType === TASK_ENTRY && entry.data?.op === "decline",
|
|
572
|
+
)
|
|
573
|
+
.map((entry) => entry.data?.record),
|
|
574
|
+
);
|
|
575
|
+
const found = (await this.registry.here(this.host.cwd())).find(({ task }) => !declined.has(task.record));
|
|
576
|
+
return found ? { id: found.id, title: found.task.title, record: found.task.record } : undefined;
|
|
577
|
+
} catch {
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
decline(record) {
|
|
582
|
+
this.write("decline", record);
|
|
583
|
+
}
|
|
584
|
+
/** Create a child of a registered task. From the model, the parent is the focus and this session must own it. */
|
|
585
|
+
async child(title, goal, parent) {
|
|
586
|
+
if (typeof title !== "string" || !title.trim()) return { ok: false, text: "Give the child a title." };
|
|
587
|
+
try {
|
|
588
|
+
let parentId = parent;
|
|
589
|
+
if (!parentId) {
|
|
590
|
+
const focus = this.current.focus;
|
|
591
|
+
const found = focus ? await this.registered(focus) : undefined;
|
|
592
|
+
if (!found) return { ok: false, text: "The focus is not a registered task, so it cannot have children." };
|
|
593
|
+
if (found.task.owner?.session !== this.host.owner().session)
|
|
594
|
+
return { ok: false, text: `Only the owner of ${found.id} creates its children.` };
|
|
595
|
+
parentId = found.id;
|
|
596
|
+
}
|
|
597
|
+
const task = await this.registry.task(parentId);
|
|
598
|
+
if (!task) return { ok: false, text: `No task ${parentId} in the registry.` };
|
|
599
|
+
const created = await this.registry.createTask(
|
|
600
|
+
task.project,
|
|
601
|
+
title.trim(),
|
|
602
|
+
String(goal || title).trim(),
|
|
603
|
+
parentId,
|
|
604
|
+
);
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
id: created.id,
|
|
608
|
+
headline: `Created child ${created.id} of ${parentId}`,
|
|
609
|
+
text: `Created child ${created.id} of ${parentId} (${created.record}). The user works it in a new session with /freeflow task attach ${created.id}.\n\n${this.delegation(parentId, created.id, title.trim(), `Created ${created.id} (${created.record}).`)}`,
|
|
610
|
+
};
|
|
611
|
+
} catch (error) {
|
|
612
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** A record's resume view, or one entry, by task ID or path. */
|
|
616
|
+
async view(record, entry, cwd) {
|
|
617
|
+
const { path, error } = await this.locate(record, cwd);
|
|
618
|
+
if (!path) return { ok: false, text: error };
|
|
619
|
+
const { text, error: readError } = await this.read(path);
|
|
620
|
+
if (!text) return { ok: false, text: `${path} is unavailable: ${readError}.` };
|
|
621
|
+
try {
|
|
622
|
+
const views = await this.views();
|
|
623
|
+
if (typeof entry === "string" && entry.trim())
|
|
624
|
+
return { ok: true, text: views.renderEntries(text, [entry.trim()]) };
|
|
625
|
+
// A registered task reads as its delivered view does: parent or children, the project's other tasks, and where
|
|
626
|
+
// its work happens, before the resume view.
|
|
627
|
+
const found = await this.registered(path);
|
|
628
|
+
const blocks = found
|
|
629
|
+
? [
|
|
630
|
+
...(await this.family(found.id, found.task)),
|
|
631
|
+
...(await this.projectLines(found.id, found.task)),
|
|
632
|
+
...(await this.environmentLines(found.id)),
|
|
633
|
+
]
|
|
634
|
+
: [];
|
|
635
|
+
const resume = views.renderResume(text);
|
|
636
|
+
return { ok: true, text: blocks.length ? `${blocks.join("\n").trimStart()}\n\n${resume}` : resume };
|
|
637
|
+
} catch (failure) {
|
|
638
|
+
return { ok: false, text: String(failure?.message ?? failure) };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** Lines matching the query in the focused record, its task tree, or its project, through Track Work's search. */
|
|
642
|
+
async search(query, scope, cwd) {
|
|
643
|
+
if (typeof query !== "string" || !query.trim()) return { ok: false, text: "Give a query." };
|
|
644
|
+
const focus = this.current.focus;
|
|
645
|
+
try {
|
|
646
|
+
const found = focus ? await this.registered(focus) : undefined;
|
|
647
|
+
let records;
|
|
648
|
+
if (found) records = await this.registry.scope(found.id, typeof scope === "string" ? scope : "task");
|
|
649
|
+
else if (scope === "project") {
|
|
650
|
+
const project = await this.registry.projectFor(cwd);
|
|
651
|
+
if (!project)
|
|
652
|
+
return { ok: false, text: "No project: the focus is unregistered and this repository is not linked to one." };
|
|
653
|
+
records = (await this.registry.projectTasks(project.slug)).map(({ id, task }) => [id, task.record]);
|
|
654
|
+
} else if (scope === "tree") return { ok: false, text: "The focus is not a registered task, so it has no tree." };
|
|
655
|
+
else if (focus) records = [[this.name(focus), focus]];
|
|
656
|
+
else return { ok: false, text: "Nothing is in focus." };
|
|
657
|
+
const { searchRecords } = await import(
|
|
658
|
+
pathToFileURL(join(this.host.packageRoot, "skills/track-work/scripts/lib/search.mjs")).href
|
|
659
|
+
);
|
|
660
|
+
return { ok: true, text: await searchRecords(records, query) };
|
|
661
|
+
} catch (error) {
|
|
662
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/** Move an unregistered record into a project's store and register it; the user confirmed before this. */
|
|
666
|
+
async importRecord(record, projectName, cwd) {
|
|
667
|
+
const { path, id, error } = await this.locate(record, cwd);
|
|
668
|
+
if (!path) return { ok: false, text: error };
|
|
669
|
+
if (id) return { ok: false, text: `${path} is already registered as ${id}.` };
|
|
670
|
+
if (typeof projectName !== "string" || !projectName.trim()) return { ok: false, text: "Name the project." };
|
|
671
|
+
try {
|
|
672
|
+
const moved = await this.registry.importRecord(projectName.trim(), path);
|
|
673
|
+
if (this.current.attached.includes(path)) {
|
|
674
|
+
this.write("detach", path);
|
|
675
|
+
this.ids.set(moved.record, moved.id);
|
|
676
|
+
const refused = await this.own(moved.id, false);
|
|
677
|
+
if (!refused) this.write("attach", moved.record);
|
|
678
|
+
}
|
|
679
|
+
return { ok: true, text: `Imported as ${moved.id} (${moved.record}).` };
|
|
680
|
+
} catch (error) {
|
|
681
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** This repository's project tasks and records under .freeflow/tasks, then attached records from elsewhere. */
|
|
685
|
+
async records(cwd) {
|
|
686
|
+
const found = [];
|
|
687
|
+
try {
|
|
688
|
+
const project = await this.registry.projectFor(cwd);
|
|
689
|
+
if (project)
|
|
690
|
+
for (const { id, task } of await this.registry.projectTasks(project.slug)) {
|
|
691
|
+
this.ids.set(task.record, id);
|
|
692
|
+
if ((await this.read(task.record)).text !== undefined) found.push(task.record);
|
|
693
|
+
}
|
|
694
|
+
} catch {
|
|
695
|
+
// An unreadable registry leaves only unregistered records.
|
|
696
|
+
}
|
|
697
|
+
const local = [];
|
|
698
|
+
try {
|
|
699
|
+
for (const entry of await readdir(join(cwd, ".freeflow", "tasks"), { withFileTypes: true })) {
|
|
700
|
+
const record = join(cwd, ".freeflow", "tasks", entry.name, "record.md");
|
|
701
|
+
if (entry.isDirectory() && (await this.read(record)).text !== undefined) local.push(record);
|
|
702
|
+
}
|
|
703
|
+
} catch {
|
|
704
|
+
// No tasks directory.
|
|
705
|
+
}
|
|
706
|
+
return [...new Set([...found, ...local.sort(), ...this.current.attached])];
|
|
707
|
+
}
|
|
708
|
+
/** Every project in the registry, marking the one this repository is linked to. */
|
|
709
|
+
async projects(cwd) {
|
|
710
|
+
try {
|
|
711
|
+
const data = await this.registry.read();
|
|
712
|
+
const here = await this.registry.projectFor(cwd).catch(() => undefined);
|
|
713
|
+
const entries = Object.entries(data.projects);
|
|
714
|
+
if (!entries.length)
|
|
715
|
+
return {
|
|
716
|
+
ok: true,
|
|
717
|
+
headline: "No projects",
|
|
718
|
+
text: "No projects yet. A project is created with the user's confirmation.",
|
|
719
|
+
};
|
|
720
|
+
const count = (slug) => Object.values(data.tasks).filter((task) => task.project === slug).length;
|
|
721
|
+
return {
|
|
722
|
+
ok: true,
|
|
723
|
+
headline: `${entries.length} project${entries.length === 1 ? "" : "s"}`,
|
|
724
|
+
text: entries
|
|
725
|
+
.map(
|
|
726
|
+
([slug, project]) =>
|
|
727
|
+
`- ${project.name} (${slug})${here?.slug === slug ? ", linked to this repository" : ""}: ${count(slug)} task(s); repositories ${project.repos.join(", ")}`,
|
|
728
|
+
)
|
|
729
|
+
.join("\n"),
|
|
730
|
+
};
|
|
731
|
+
} catch (error) {
|
|
732
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/** Create a project and link this repository, or link this repository to an existing one; the user confirmed. */
|
|
736
|
+
async project(operation, name, worktrees, cwd) {
|
|
737
|
+
if (typeof name !== "string" || !name.trim()) return { ok: false, text: "Name the project." };
|
|
738
|
+
const where = typeof worktrees === "string" && worktrees.trim() ? worktrees.trim() : undefined;
|
|
739
|
+
try {
|
|
740
|
+
const slug =
|
|
741
|
+
operation === "new"
|
|
742
|
+
? await this.registry.createProject(name.trim(), cwd, where)
|
|
743
|
+
: await this.registry.linkProject(name.trim(), cwd, where);
|
|
744
|
+
const repo = await this.registry.repoRoot(cwd);
|
|
745
|
+
const link = join(repo, ".freeflow", "project");
|
|
746
|
+
const browse = existsSync(link) ? ` The user can browse its records at ${link}.` : "";
|
|
747
|
+
const text =
|
|
748
|
+
operation === "new"
|
|
749
|
+
? `Project ${slug} created and linked to ${repo}.${browse}`
|
|
750
|
+
: `${repo} is linked to project ${slug}.${browse}`;
|
|
751
|
+
return { ok: true, headline: text, text };
|
|
752
|
+
} catch (error) {
|
|
753
|
+
return { ok: false, text: error?.message ?? String(error) };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** Removal names a registered task by ID, or a project by name (Task 006). */
|
|
757
|
+
removalTarget(target) {
|
|
758
|
+
const task = typeof target.task === "string" ? target.task.trim() : "";
|
|
759
|
+
const project = typeof target.project === "string" ? target.project.trim() : "";
|
|
760
|
+
if (project) return { target: { project } };
|
|
761
|
+
if (!task) return { error: "Name the task's ID, or the project." };
|
|
762
|
+
if (!TASK_ID.test(task))
|
|
763
|
+
return {
|
|
764
|
+
error: `${task} is not a task ID: only registered tasks (such as my-project/task-003) and projects can be removed.`,
|
|
765
|
+
};
|
|
766
|
+
return { target: { task } };
|
|
767
|
+
}
|
|
768
|
+
/** The question to put to the user before removing a task or a project, or why it cannot be removed. */
|
|
769
|
+
async removalQuestion(target) {
|
|
770
|
+
const { target: named, error } = this.removalTarget(target);
|
|
771
|
+
if (!named) return { error };
|
|
772
|
+
try {
|
|
773
|
+
const plan = await this.registry.removal(named);
|
|
774
|
+
const [first, ...children] = plan.tasks;
|
|
775
|
+
const what = plan.project
|
|
776
|
+
? `project ${plan.project.name} and its ${plan.tasks.length} task(s)`
|
|
777
|
+
: `${first.id} "${first.title}"${children.length ? ` and its children ${children.map((task) => task.id).join(", ")}` : ""}`;
|
|
778
|
+
const me = this.host.owner().session;
|
|
779
|
+
const others = plan.tasks.filter((task) => task.owner && task.owner !== me);
|
|
780
|
+
return {
|
|
781
|
+
question: [
|
|
782
|
+
`Remove ${what}? The records move to the trash; only you can empty it, with /freeflow trash empty.`,
|
|
783
|
+
...(others.length
|
|
784
|
+
? [`Owned by other sessions: ${others.map((task) => `${task.id} (session ${task.owner})`).join(", ")}.`]
|
|
785
|
+
: []),
|
|
786
|
+
...(plan.worktrees.length ? [`Git worktrees stay: ${plan.worktrees.map((w) => w.path).join(", ")}.`] : []),
|
|
787
|
+
].join(" "),
|
|
788
|
+
};
|
|
789
|
+
} catch (failure) {
|
|
790
|
+
return { error: failure?.message ?? String(failure) };
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Remove a task with its children, or a project; the user confirmed. Removed records are detached here. */
|
|
794
|
+
async remove(target) {
|
|
795
|
+
const { target: named, error } = this.removalTarget(target);
|
|
796
|
+
if (!named) return { ok: false, text: error };
|
|
797
|
+
try {
|
|
798
|
+
const plan = await this.registry.removal(named);
|
|
799
|
+
const records = (await Promise.all(plan.tasks.map((task) => this.registry.task(task.id))))
|
|
800
|
+
.map((task) => task?.record)
|
|
801
|
+
.filter((record) => typeof record === "string");
|
|
802
|
+
const result = named.task
|
|
803
|
+
? await this.registry.removeTask(named.task)
|
|
804
|
+
: await this.registry.removeProject(named.project);
|
|
805
|
+
for (const record of records) if (this.current.attached.includes(record)) this.write("detach", record);
|
|
806
|
+
return {
|
|
807
|
+
ok: true,
|
|
808
|
+
headline: `Removed ${result.project ? `project ${result.project.name}` : result.tasks[0].id}${result.tasks.length > (result.project ? 0 : 1) ? ` and ${result.tasks.length - (result.project ? 0 : 1)} task(s)` : ""} · in the trash`,
|
|
809
|
+
text: await this.registry.removalText(result),
|
|
810
|
+
};
|
|
811
|
+
} catch (failure) {
|
|
812
|
+
return { ok: false, text: failure?.message ?? String(failure) };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/** A registered task's directories, or a change to them; the user confirmed a change. */
|
|
816
|
+
async environment(record, change, cwd) {
|
|
817
|
+
const { id, error } = await this.locate(record, cwd);
|
|
818
|
+
if (error) return { ok: false, text: error };
|
|
819
|
+
if (!id) return { ok: false, text: `${record} is not a registered task, so it has no recorded directories.` };
|
|
820
|
+
try {
|
|
821
|
+
if (change.primary || change.add || change.remove) await this.registry.setEnvironment(id, change);
|
|
822
|
+
const lines = (await this.environmentLines(id)).filter(Boolean);
|
|
823
|
+
return {
|
|
824
|
+
ok: true,
|
|
825
|
+
headline: lines[0] ?? `${id} has no directory recorded`,
|
|
826
|
+
text: lines.length ? lines.join("\n") : `${id} has no directory recorded.`,
|
|
827
|
+
};
|
|
828
|
+
} catch (failure) {
|
|
829
|
+
return { ok: false, text: failure?.message ?? String(failure) };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/** This repository's records (see records()), or a named project's tasks. */
|
|
833
|
+
async list(cwd, project) {
|
|
834
|
+
let records;
|
|
835
|
+
if (typeof project === "string" && project.trim()) {
|
|
836
|
+
const found = await this.registry.project(project.trim());
|
|
837
|
+
if (!found) return `No project named ${project.trim()}.`;
|
|
838
|
+
records = [];
|
|
839
|
+
for (const { id, task } of await this.registry.projectTasks(found.slug)) {
|
|
840
|
+
this.ids.set(task.record, id);
|
|
841
|
+
records.push(task.record);
|
|
842
|
+
}
|
|
843
|
+
if (!records.length) return `Project ${found.project.name} has no tasks yet.`;
|
|
844
|
+
} else records = await this.records(cwd);
|
|
845
|
+
const lines = [];
|
|
846
|
+
for (const record of records) {
|
|
847
|
+
const { text } = await this.read(record);
|
|
848
|
+
if (text === undefined && !this.current.attached.includes(record)) continue;
|
|
849
|
+
const state = text ? briefOf(text).state : "unavailable";
|
|
850
|
+
const role =
|
|
851
|
+
this.current.focus === record
|
|
852
|
+
? ", focus"
|
|
853
|
+
: this.current.held.includes(record)
|
|
854
|
+
? ", attached (held)"
|
|
855
|
+
: this.current.attached.includes(record)
|
|
856
|
+
? ", attached"
|
|
857
|
+
: "";
|
|
858
|
+
const found = this.ids.has(record) ? await this.registered(record) : undefined;
|
|
859
|
+
const owner = found?.task.owner
|
|
860
|
+
? found.task.owner.session === this.host.owner().session
|
|
861
|
+
? ", owned here"
|
|
862
|
+
: ", owned by another session"
|
|
863
|
+
: "";
|
|
864
|
+
const parent = found?.task.parent ? `, child of ${found.task.parent}` : "";
|
|
865
|
+
lines.push(`- ${this.label(record)} — ${record} (${state}${role}${owner}${parent})`);
|
|
866
|
+
}
|
|
867
|
+
return lines.length ? lines.join("\n") : "No Working Records found for this repository and none attached.";
|
|
868
|
+
}
|
|
869
|
+
/** A record's label, task state and Next useful action, for one-line displays. */
|
|
870
|
+
async brief(record) {
|
|
871
|
+
const { text } = await this.read(record);
|
|
872
|
+
const brief = text ? briefOf(text) : { state: "unavailable", next: "" };
|
|
873
|
+
return { label: this.label(record), state: brief.state, next: brief.next };
|
|
874
|
+
}
|
|
875
|
+
attachedText() {
|
|
876
|
+
if (!this.current.attached.length) return "No task record is attached.";
|
|
877
|
+
return `Attached: ${this.current.attached.map((item) => this.label(item)).join(", ")}.`;
|
|
878
|
+
}
|
|
879
|
+
/** Lines for the Runtime State, or undefined when task memory is off. */
|
|
880
|
+
runtimeLines() {
|
|
881
|
+
if (!this.host.effective()) return undefined;
|
|
882
|
+
const { attached, held, focus } = this.current;
|
|
883
|
+
if (!attached.length)
|
|
884
|
+
return [
|
|
885
|
+
"Task Memory:",
|
|
886
|
+
"- Attached: none. Propose attaching a record with task_memory only when Track Work calls for one.",
|
|
887
|
+
];
|
|
888
|
+
return [
|
|
889
|
+
"Task Memory:",
|
|
890
|
+
...attached.map(
|
|
891
|
+
(item) =>
|
|
892
|
+
`- ${item === focus ? "Focus" : "Attached"}: ${this.label(item)} (\`${item}\`)${held.includes(item) ? ", held: not writable until the user confirms" : ""}`,
|
|
893
|
+
),
|
|
894
|
+
...(focus ? [] : ["- Focus: none"]),
|
|
895
|
+
];
|
|
896
|
+
}
|
|
897
|
+
/** A refusal for a tool call that would write a record this session has not attached, or does not own. */
|
|
898
|
+
async gate(toolName, input, cwd) {
|
|
899
|
+
if (!this.host.effective()) return undefined;
|
|
900
|
+
if (toolName === "bash" && typeof input?.command === "string") {
|
|
901
|
+
const changes = (segment) =>
|
|
902
|
+
(REGISTRY_CHANGE.test(segment) || ENVIRONMENT_CHANGE.test(segment)) && !/(^|\s)--help(\s|$)/.test(segment);
|
|
903
|
+
if (
|
|
904
|
+
expandAssignments(input.command)
|
|
905
|
+
.split(/;|&&|\|\||\||\n/)
|
|
906
|
+
.some(changes)
|
|
907
|
+
)
|
|
908
|
+
return {
|
|
909
|
+
block: true,
|
|
910
|
+
reason:
|
|
911
|
+
"Task memory: creating, fanning out or removing tasks, creating, linking or removing projects, and changing a task's directories go through the task_memory tool, which asks the user. Use its action; do not run Track Work's command for it.",
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
for (const named of recordTargets(toolName, input, cwd, this.current.attached)) {
|
|
915
|
+
if (named.startsWith(UNRESOLVED_TARGET))
|
|
916
|
+
return {
|
|
917
|
+
block: true,
|
|
918
|
+
reason: `Task memory: this Track Work command writes a record whose path cannot be read from the command (${named.slice(UNRESOLVED_TARGET.length)}), so it cannot be checked against the records attached to this session. Run it with a literal --record path or task ID.`,
|
|
919
|
+
};
|
|
920
|
+
let target = named;
|
|
921
|
+
if (named.startsWith(ID_TARGET)) {
|
|
922
|
+
const task = await this.registry.task(named.slice(ID_TARGET.length)).catch(() => undefined);
|
|
923
|
+
if (!task) continue;
|
|
924
|
+
target = task.record;
|
|
925
|
+
}
|
|
926
|
+
if (!this.current.attached.includes(target))
|
|
927
|
+
return {
|
|
928
|
+
block: true,
|
|
929
|
+
reason: `Task memory: ${target} is not attached to this session, so it cannot be written. ${this.attachedText()} Propose attaching it with task_memory, or ask the user; do not write it another way.`,
|
|
930
|
+
};
|
|
931
|
+
if (this.current.held.includes(target))
|
|
932
|
+
return {
|
|
933
|
+
block: true,
|
|
934
|
+
reason: `Task memory: ${target} was inherited by a fork or clone and is held. Only the user releases it, with /freeflow task confirm; do not write it another way.`,
|
|
935
|
+
};
|
|
936
|
+
let found;
|
|
937
|
+
try {
|
|
938
|
+
found = await this.registry.byRecord(target);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
// Without the registry, ownership of a stored record is unknown: refuse rather than risk two writers.
|
|
941
|
+
if (target.startsWith(freeflowHome() + sep))
|
|
942
|
+
return {
|
|
943
|
+
block: true,
|
|
944
|
+
reason: `Task memory: ${error?.message ?? error} Ownership of ${target} cannot be checked, so it cannot be written until the registry is repaired.`,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
if (found && found.task.owner?.session !== this.host.owner().session)
|
|
948
|
+
return {
|
|
949
|
+
block: true,
|
|
950
|
+
reason: `Task memory: ${found.id} is owned by ${found.task.owner ? `session ${found.task.owner.session}` : "no session"}, not this one, so this session cannot write it. The user takes it over with /freeflow task attach ${found.id}; do not write it another way.`,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
/** Whether the branch already holds a snapshot of the record since its latest compaction. */
|
|
956
|
+
delivered(branch, record) {
|
|
957
|
+
let start = 0;
|
|
958
|
+
for (let index = branch.length - 1; index >= 0; index--)
|
|
959
|
+
if (branch[index]?.type === "compaction") {
|
|
960
|
+
start = index + 1;
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
return branch
|
|
964
|
+
.slice(start)
|
|
965
|
+
.some(
|
|
966
|
+
(entry) =>
|
|
967
|
+
(entry?.type === "custom_message" &&
|
|
968
|
+
entry.customType === SNAPSHOT_TYPE &&
|
|
969
|
+
entry.details?.record === record) ||
|
|
970
|
+
(entry?.type === "message" &&
|
|
971
|
+
entry.message?.role === "toolResult" &&
|
|
972
|
+
entry.message.toolName === "task_memory" &&
|
|
973
|
+
entry.message.details?.snapshot === record),
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
/** Deliver the focus's snapshot as a message unless the branch already has one since the latest compaction. */
|
|
977
|
+
async deliver(branch) {
|
|
978
|
+
const focus = this.current.focus;
|
|
979
|
+
if (!this.host.effective() || !focus || this.delivered(branch, focus)) return;
|
|
980
|
+
this.host.send(await this.snapshot(focus), focus);
|
|
981
|
+
}
|
|
982
|
+
/** The snapshot as a boundary entry, for Freeflow compaction's turn end. */
|
|
983
|
+
async snapshotEntry() {
|
|
984
|
+
const focus = this.current.focus;
|
|
985
|
+
if (!this.host.effective() || !focus) return undefined;
|
|
986
|
+
return {
|
|
987
|
+
type: "custom_message",
|
|
988
|
+
customType: SNAPSHOT_TYPE,
|
|
989
|
+
content: await this.snapshot(focus),
|
|
990
|
+
display: true,
|
|
991
|
+
details: { record: focus },
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
}
|