@pennixrv/trellis 0.6.24 → 0.6.26
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/migrations/manifests/0.6.24.json +9 -0
- package/dist/migrations/manifests/0.6.25.json +9 -0
- package/dist/templates/opencode/lib/trellis-context.js +6 -4
- package/dist/templates/opencode/plugins/inject-subagent-context.js +4 -5
- package/dist/templates/trellis/index.d.ts +1 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +2 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/active_task.py +7 -6
- package/dist/templates/trellis/scripts/common/continuation_record.py +439 -0
- package/dist/templates/trellis/scripts/common/session_context.py +7 -0
- package/dist/templates/trellis/scripts/task.py +47 -0
- package/package.json +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.6.24",
|
|
3
|
+
"description": "Publishes the bounded semantic RecoveryBrief and formal handoff workflow contract.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Enhancements:**\n- Adds the bounded semantic RecoveryBrief and formal handoff contract to the Pennix Marketplace `codex-subnode-channel` workflow.\n- Aligns the published Trellis workflow source with the Pennix Marketplace v0.6.24 release.\n\n**Internal:**\n- Restores the missing release manifest so adjacent-version `trellis update` paths remain complete.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "No project migration is required. Existing projects can run the normal `trellis update` flow."
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.6.25",
|
|
3
|
+
"description": "Prevents a known target session from inheriting a foreign active-task pointer.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Bug fixes:**\n- Keeps session-scoped active-task resolution isolated when a known session has no usable pointer.\n- Preserves identity-less single-session fallback for compatible sub-agent environments.\n\n**Internal:**\n- Restores the missing release manifest so adjacent-version `trellis update` paths remain complete.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "No project migration is required. Existing projects can run the normal `trellis update` flow."
|
|
9
|
+
}
|
|
@@ -372,10 +372,11 @@ export class TrellisContext {
|
|
|
372
372
|
* Get active task from session runtime context.
|
|
373
373
|
*
|
|
374
374
|
* Resolution order (mirrors Python `active_task.resolve_active_task`):
|
|
375
|
-
* 1. Lookup the runtime file for the input-derived context key.
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
375
|
+
* 1. Lookup the runtime file for the input-derived context key. A known
|
|
376
|
+
* key is authoritative, so a missing or empty context returns no task.
|
|
377
|
+
* 2. If no context key is available and exactly one session runtime file
|
|
378
|
+
* exists locally, use it (`_resolveSingleSessionFallback`). Refuses to
|
|
379
|
+
* guess when 0 or ≥2 files exist so multi-window isolation holds.
|
|
379
380
|
*/
|
|
380
381
|
getActiveTask(platformInput = null) {
|
|
381
382
|
const contextKey = this.getContextKey(platformInput)
|
|
@@ -390,6 +391,7 @@ export class TrellisContext {
|
|
|
390
391
|
stale: !taskDir || !existsSync(taskDir),
|
|
391
392
|
}
|
|
392
393
|
}
|
|
394
|
+
return { taskPath: null, source: "none", stale: false }
|
|
393
395
|
}
|
|
394
396
|
|
|
395
397
|
const fallback = this._resolveSingleSessionFallback()
|
|
@@ -471,10 +471,9 @@ export default async ({ directory, platform: hostPlatform = process.platform, en
|
|
|
471
471
|
// run when earlier ones miss):
|
|
472
472
|
// 1. Exact session runtime context lookup for input.sessionID
|
|
473
473
|
// 2. `Active task: <path>` hint in the dispatch prompt
|
|
474
|
-
// (explicit per-dispatch override
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
// runtime file exists locally
|
|
474
|
+
// (explicit per-dispatch override)
|
|
475
|
+
// 3. Single-session fallback only when the input has no session
|
|
476
|
+
// identity and exactly 1 runtime file exists locally
|
|
478
477
|
let taskDir = null
|
|
479
478
|
let taskSource = null
|
|
480
479
|
|
|
@@ -503,7 +502,7 @@ export default async ({ directory, platform: hostPlatform = process.platform, en
|
|
|
503
502
|
}
|
|
504
503
|
}
|
|
505
504
|
|
|
506
|
-
if (!taskDir) {
|
|
505
|
+
if (!taskDir && !contextKey) {
|
|
507
506
|
const fallback = ctx._resolveSingleSessionFallback()
|
|
508
507
|
if (fallback?.taskPath) {
|
|
509
508
|
const fallbackDir = ctx.resolveTaskDir(fallback.taskPath)
|
|
@@ -36,6 +36,7 @@ export declare const commonTasks: string;
|
|
|
36
36
|
export declare const commonTaskContext: string;
|
|
37
37
|
export declare const commonTaskStore: string;
|
|
38
38
|
export declare const commonSessionContext: string;
|
|
39
|
+
export declare const commonContinuationRecord: string;
|
|
39
40
|
export declare const commonPackagesContext: string;
|
|
40
41
|
export declare const commonWorkflowPhase: string;
|
|
41
42
|
export declare const commonTrellisConfig: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAcH,eAAO,MAAM,WAAW,QAAsC,CAAC;AAG/D,eAAO,MAAM,UAAU,QAA6C,CAAC;AACrE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,eAAe,QAA8C,CAAC;AAC3E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,YAAY,QAA2C,CAAC;AACrE,eAAO,MAAM,QAAQ,QAAuC,CAAC;AAC7D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAChF,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,oBAAoB,QAEhC,CAAC;AACF,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAG9E,eAAO,MAAM,kBAAkB,QAA2C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAC7E,eAAO,MAAM,UAAU,QAAkC,CAAC;AAC1D,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAG7E,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,iBAAiB,QAAgC,CAAC;AAC/D,eAAO,MAAM,qBAAqB,QAAoC,CAAC;AAMvE,eAAO,MAAM,sBAAsB,QAAsC,CAAC;AAC1E,eAAO,MAAM,kBAAkB,QAAkC,CAAC;AAClE,eAAO,MAAM,oBAAoB,QAAoC,CAAC;AAEtE;;GAEG;AACH,wBAAgB,aAAa,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAcH,eAAO,MAAM,WAAW,QAAsC,CAAC;AAG/D,eAAO,MAAM,UAAU,QAA6C,CAAC;AACrE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,eAAe,QAA8C,CAAC;AAC3E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAC9E,eAAO,MAAM,YAAY,QAA2C,CAAC;AACrE,eAAO,MAAM,QAAQ,QAAuC,CAAC;AAC7D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,SAAS,QAAwC,CAAC;AAC/D,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,WAAW,QAA0C,CAAC;AACnE,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAChF,eAAO,MAAM,eAAe,QAA+C,CAAC;AAC5E,eAAO,MAAM,oBAAoB,QAEhC,CAAC;AACF,eAAO,MAAM,wBAAwB,QAEpC,CAAC;AACF,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,mBAAmB,QAE/B,CAAC;AACF,eAAO,MAAM,gBAAgB,QAAgD,CAAC;AAG9E,eAAO,MAAM,kBAAkB,QAA2C,CAAC;AAC3E,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAC7E,eAAO,MAAM,UAAU,QAAkC,CAAC;AAC1D,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,gBAAgB,QAAyC,CAAC;AACvE,eAAO,MAAM,qBAAqB,QAEjC,CAAC;AACF,eAAO,MAAM,mBAAmB,QAA4C,CAAC;AAG7E,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,kBAAkB,QAA8B,CAAC;AAC9D,eAAO,MAAM,iBAAiB,QAAgC,CAAC;AAC/D,eAAO,MAAM,qBAAqB,QAAoC,CAAC;AAMvE,eAAO,MAAM,sBAAsB,QAAsC,CAAC;AAC1E,eAAO,MAAM,kBAAkB,QAAkC,CAAC;AAClE,eAAO,MAAM,oBAAoB,QAAoC,CAAC;AAEtE;;GAEG;AACH,wBAAgB,aAAa,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAwCnD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAMlD"}
|
|
@@ -46,6 +46,7 @@ export const commonTasks = readTemplate("scripts/common/tasks.py");
|
|
|
46
46
|
export const commonTaskContext = readTemplate("scripts/common/task_context.py");
|
|
47
47
|
export const commonTaskStore = readTemplate("scripts/common/task_store.py");
|
|
48
48
|
export const commonSessionContext = readTemplate("scripts/common/session_context.py");
|
|
49
|
+
export const commonContinuationRecord = readTemplate("scripts/common/continuation_record.py");
|
|
49
50
|
export const commonPackagesContext = readTemplate("scripts/common/packages_context.py");
|
|
50
51
|
export const commonWorkflowPhase = readTemplate("scripts/common/workflow_phase.py");
|
|
51
52
|
export const commonTrellisConfig = readTemplate("scripts/common/trellis_config.py");
|
|
@@ -95,6 +96,7 @@ export function getAllScripts() {
|
|
|
95
96
|
scripts.set("common/task_context.py", commonTaskContext);
|
|
96
97
|
scripts.set("common/task_store.py", commonTaskStore);
|
|
97
98
|
scripts.set("common/session_context.py", commonSessionContext);
|
|
99
|
+
scripts.set("common/continuation_record.py", commonContinuationRecord);
|
|
98
100
|
scripts.set("common/packages_context.py", commonPackagesContext);
|
|
99
101
|
scripts.set("common/workflow_phase.py", commonWorkflowPhase);
|
|
100
102
|
scripts.set("common/trellis_config.py", commonTrellisConfig);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,SAAS,YAAY,CAAC,YAAoB;IACxC,OAAO,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC;AAC9D,CAAC;AAED,gCAAgC;AAChC,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,qBAAqB,CAAC,CAAC;AAE/D,0BAA0B;AAC1B,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC,4BAA4B,CAAC,CAAC;AACrE,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,yBAAyB,CAAC,CAAC;AACnE,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,6BAA6B,CAAC,CAAC;AAC3E,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC,+BAA+B,CAAC,CAAC;AAC9E,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,8BAA8B,CAAC,CAAC;AAC5E,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,8BAA8B,CAAC,CAAC;AAC5E,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC,+BAA+B,CAAC,CAAC;AAC9E,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC,+BAA+B,CAAC,CAAC;AAC9E,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,0BAA0B,CAAC,CAAC;AACrE,MAAM,CAAC,MAAM,QAAQ,GAAG,YAAY,CAAC,sBAAsB,CAAC,CAAC;AAC7D,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,uBAAuB,CAAC,CAAC;AAC/D,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,uBAAuB,CAAC,CAAC;AAC/D,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,yBAAyB,CAAC,CAAC;AACnE,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,yBAAyB,CAAC,CAAC;AACnE,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,gCAAgC,CAAC,CAAC;AAChF,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,8BAA8B,CAAC,CAAC;AAC5E,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAC9C,mCAAmC,CACpC,CAAC;AACF,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAAY,CAC/C,oCAAoC,CACrC,CAAC;AACF,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAC7C,kCAAkC,CACnC,CAAC;AACF,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAC7C,kCAAkC,CACnC,CAAC;AACF,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC,+BAA+B,CAAC,CAAC;AAE9E,wBAAwB;AACxB,MAAM,CAAC,MAAM,kBAAkB,GAAG,YAAY,CAAC,0BAA0B,CAAC,CAAC;AAC3E,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAAC,2BAA2B,CAAC,CAAC;AAC7E,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAC1D,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC,wBAAwB,CAAC,CAAC;AACvE,MAAM,CAAC,MAAM,gBAAgB,GAAG,YAAY,CAAC,wBAAwB,CAAC,CAAC;AACvE,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAAY,CAC/C,6BAA6B,CAC9B,CAAC;AACF,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAAC,2BAA2B,CAAC,CAAC;AAE7E,sBAAsB;AACtB,MAAM,CAAC,MAAM,kBAAkB,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;AAC9D,MAAM,CAAC,MAAM,kBAAkB,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;AAC9D,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC;AAC/D,MAAM,CAAC,MAAM,qBAAqB,GAAG,YAAY,CAAC,mBAAmB,CAAC,CAAC;AAEvE,+CAA+C;AAC/C,gFAAgF;AAChF,iFAAiF;AACjF,qCAAqC;AACrC,MAAM,CAAC,MAAM,sBAAsB,GAAG,YAAY,CAAC,qBAAqB,CAAC,CAAC;AAC1E,MAAM,CAAC,MAAM,kBAAkB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAClE,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC,mBAAmB,CAAC,CAAC;AAEtE;;GAEG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAE1C,eAAe;IACf,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,WAAW,CAAC,CAAC;IAExC,SAAS;IACT,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,UAAU,CAAC,CAAC;IAC9C,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,WAAW,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,CAAC,qBAAqB,EAAE,eAAe,CAAC,CAAC;IACpD,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,gBAAgB,CAAC,CAAC;IACvD,OAAO,CAAC,GAAG,CAAC,sBAAsB,EAAE,eAAe,CAAC,CAAC;IACrD,OAAO,CAAC,GAAG,CAAC,sBAAsB,EAAE,eAAe,CAAC,CAAC;IACrD,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,gBAAgB,CAAC,CAAC;IACvD,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,gBAAgB,CAAC,CAAC;IACvD,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC;IAC9C,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,QAAQ,CAAC,CAAC;IACtC,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,SAAS,CAAC,CAAC;IACxC,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,SAAS,CAAC,CAAC;IACxC,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,WAAW,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,WAAW,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,CAAC,wBAAwB,EAAE,iBAAiB,CAAC,CAAC;IACzD,OAAO,CAAC,GAAG,CAAC,sBAAsB,EAAE,eAAe,CAAC,CAAC;IACrD,OAAO,CAAC,GAAG,CAAC,2BAA2B,EAAE,oBAAoB,CAAC,CAAC;IAC/D,OAAO,CAAC,GAAG,CAAC,4BAA4B,EAAE,qBAAqB,CAAC,CAAC;IACjE,OAAO,CAAC,GAAG,CAAC,0BAA0B,EAAE,mBAAmB,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,0BAA0B,EAAE,mBAAmB,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,gBAAgB,CAAC,CAAC;IAEvD,OAAO;IACP,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,CAAC;IACpD,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,mBAAmB,CAAC,CAAC;IACtD,OAAO,CAAC,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACnC,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC;IAChD,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAC;IAChD,OAAO,CAAC,GAAG,CAAC,qBAAqB,EAAE,qBAAqB,CAAC,CAAC;IAC1D,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,mBAAmB,CAAC,CAAC;IAEtD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY;IAC1B,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,sBAAsB,CAAC,CAAC;IACnD,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,kBAAkB,CAAC,CAAC;IAC3C,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,oBAAoB,CAAC,CAAC;IAC/C,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/trellis/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -653,12 +653,12 @@ def resolve_active_task(
|
|
|
653
653
|
) -> ActiveTask:
|
|
654
654
|
"""Resolve the active task from session runtime state only.
|
|
655
655
|
|
|
656
|
-
A stale session task is returned as stale.
|
|
657
|
-
missing
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
656
|
+
A stale session task is returned as stale. A known context identity is
|
|
657
|
+
authoritative: a missing or empty context for that identity returns no
|
|
658
|
+
task. When context identity is unavailable, single-session inference may
|
|
659
|
+
cover pull-based platform sub-agents (copilot, gemini, qoder) that don't
|
|
660
|
+
inherit the parent's session id. ≥2 files or 0 files yield
|
|
661
|
+
ActiveTask(None) — refuses to guess across windows.
|
|
662
662
|
"""
|
|
663
663
|
context_key = resolve_context_key(
|
|
664
664
|
platform_input,
|
|
@@ -671,6 +671,7 @@ def resolve_active_task(
|
|
|
671
671
|
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
|
672
672
|
if active:
|
|
673
673
|
return active
|
|
674
|
+
return ActiveTask(None, "none", context_key)
|
|
674
675
|
|
|
675
676
|
if allow_single_session_fallback:
|
|
676
677
|
fallback = _resolve_single_session_fallback(repo_root)
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Bounded, task-scoped Continuation Record runtime projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .active_task import resolve_active_task
|
|
16
|
+
from .git import run_git
|
|
17
|
+
from .io import read_json_checked
|
|
18
|
+
from .paths import FILE_TASK_JSON, get_repo_root
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
KIND = "trellis-continuation-record"
|
|
23
|
+
STATUS_ABSENT = "absent"
|
|
24
|
+
STATUS_READY = "ready"
|
|
25
|
+
STATUS_STALE = "stale"
|
|
26
|
+
STATUS_WITHHELD = "withheld"
|
|
27
|
+
MAX_RECORD_BYTES = 128 * 1024
|
|
28
|
+
MAX_REQUEST_BYTES = 64 * 1024
|
|
29
|
+
MAX_TEXT_BYTES = 4096
|
|
30
|
+
MAX_LIST = 32
|
|
31
|
+
MAX_EVIDENCE = 32
|
|
32
|
+
MAX_EVIDENCE_BYTES = 2 * 1024 * 1024
|
|
33
|
+
MAX_TASK_FILES = 512
|
|
34
|
+
MAX_TASK_FILE_BYTES = 2 * 1024 * 1024
|
|
35
|
+
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
36
|
+
SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
37
|
+
SECRET = re.compile(
|
|
38
|
+
r"(?:-----BEGIN [A-Z0-9 ]+ PRIVATE KEY-----|bearer\s+[A-Za-z0-9._~+/=-]{20,}|"
|
|
39
|
+
r"\b(?:sk|th)-[A-Za-z0-9_-]{20,}|(?:api[_-]?key|password|secret)\s*[:=]\s*\S+)",
|
|
40
|
+
re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContinuationError(ValueError):
|
|
45
|
+
"""Raised when a Continuation Record cannot be safely read or written."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NoDirectCurrentTask(ContinuationError):
|
|
49
|
+
"""Raised when the current session has no directly bound active task."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _canonical(value: Any) -> bytes:
|
|
53
|
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _digest(value: Any) -> str:
|
|
57
|
+
return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _text(value: Any, label: str, maximum: int = MAX_TEXT_BYTES) -> str:
|
|
61
|
+
if not isinstance(value, str) or not value.strip() or len(value.encode("utf-8")) > maximum:
|
|
62
|
+
raise ContinuationError(f"{label} is invalid")
|
|
63
|
+
if any(ord(char) < 32 and char not in "\n\t" for char in value) or SECRET.search(value):
|
|
64
|
+
raise ContinuationError(f"{label} is unsafe")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _list(value: Any, label: str) -> list[str]:
|
|
69
|
+
if not isinstance(value, list) or len(value) > MAX_LIST:
|
|
70
|
+
raise ContinuationError(f"{label} is invalid")
|
|
71
|
+
return [_text(item, f"{label}[{index}]") for index, item in enumerate(value)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_task_id(task_id: Any) -> str:
|
|
75
|
+
value = _text(task_id, "task.id", 128)
|
|
76
|
+
if not SAFE_ID.fullmatch(value) or "/" in value or "\\" in value or value in {".", ".."}:
|
|
77
|
+
raise ContinuationError("task.id is unsafe")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _record_path(root: Path, task_id: str) -> Path:
|
|
82
|
+
safe_id = _safe_task_id(task_id)
|
|
83
|
+
runtime = root / ".trellis" / ".runtime"
|
|
84
|
+
records = runtime / "continuation-records"
|
|
85
|
+
task_dir = records / safe_id
|
|
86
|
+
if runtime.is_symlink() or records.is_symlink() or task_dir.is_symlink():
|
|
87
|
+
raise ContinuationError("continuation record directory is unsafe")
|
|
88
|
+
return task_dir / "record.json"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _regular(path: Path, label: str) -> Path:
|
|
92
|
+
if path.is_symlink() or not path.is_file():
|
|
93
|
+
raise ContinuationError(f"{label} is not a regular file")
|
|
94
|
+
return path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _project_file(root: Path, value: Any, label: str) -> Path:
|
|
98
|
+
relative = _text(value, label, 1024)
|
|
99
|
+
candidate = Path(relative)
|
|
100
|
+
if candidate.is_absolute() or ".." in candidate.parts or relative.startswith(".trellis/.runtime/"):
|
|
101
|
+
raise ContinuationError(f"{label} must be project-relative")
|
|
102
|
+
cursor = root
|
|
103
|
+
for part in candidate.parts:
|
|
104
|
+
cursor /= part
|
|
105
|
+
if cursor.is_symlink():
|
|
106
|
+
raise ContinuationError(f"{label} contains a symbolic path")
|
|
107
|
+
resolved = (root / candidate).resolve()
|
|
108
|
+
try:
|
|
109
|
+
resolved.relative_to(root.resolve())
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
raise ContinuationError(f"{label} escapes project") from exc
|
|
112
|
+
return resolved
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _file_digest(path: Path, maximum: int = MAX_EVIDENCE_BYTES) -> tuple[str, int]:
|
|
116
|
+
hasher = hashlib.sha256()
|
|
117
|
+
size = 0
|
|
118
|
+
with _regular(path, "evidence file").open("rb") as handle:
|
|
119
|
+
while chunk := handle.read(256 * 1024):
|
|
120
|
+
size += len(chunk)
|
|
121
|
+
if size > maximum:
|
|
122
|
+
raise ContinuationError("source file is too large")
|
|
123
|
+
hasher.update(chunk)
|
|
124
|
+
return "sha256:" + hasher.hexdigest(), size
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _task_source(root: Path) -> dict[str, Any]:
|
|
128
|
+
active = resolve_active_task(root)
|
|
129
|
+
if not active.task_path:
|
|
130
|
+
raise NoDirectCurrentTask("no_direct_current_task")
|
|
131
|
+
if active.source_type == "session-fallback":
|
|
132
|
+
raise ContinuationError("session_fallback_untrusted")
|
|
133
|
+
if active.source_type != "session" or not active.context_key:
|
|
134
|
+
raise NoDirectCurrentTask("no_direct_current_task")
|
|
135
|
+
if active.stale:
|
|
136
|
+
raise ContinuationError("current_session_pointer_stale")
|
|
137
|
+
task_path = _project_file(root, active.task_path, "task path")
|
|
138
|
+
task_file = _regular(task_path / FILE_TASK_JSON, "task.json")
|
|
139
|
+
data, reason = read_json_checked(task_file)
|
|
140
|
+
if data is None:
|
|
141
|
+
raise ContinuationError(f"task.json is {reason}")
|
|
142
|
+
task_id = _safe_task_id(data.get("id") or data.get("name"))
|
|
143
|
+
entries: list[dict[str, Any]] = []
|
|
144
|
+
for item in sorted(task_path.rglob("*")):
|
|
145
|
+
if item.is_symlink() or not item.is_file():
|
|
146
|
+
continue
|
|
147
|
+
if len(entries) >= MAX_TASK_FILES:
|
|
148
|
+
raise ContinuationError("task has too many material files")
|
|
149
|
+
item_digest, size = _file_digest(item, MAX_TASK_FILE_BYTES)
|
|
150
|
+
entries.append({"path": item.relative_to(root).as_posix(), "bytes": size, "sha256": item_digest})
|
|
151
|
+
return {
|
|
152
|
+
"id": task_id,
|
|
153
|
+
"path": active.task_path,
|
|
154
|
+
"status": _text(data.get("status"), "task.status", 64),
|
|
155
|
+
"material_digest": _digest(entries),
|
|
156
|
+
"context_key": active.context_key,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _git_source(root: Path) -> dict[str, Any]:
|
|
161
|
+
def run(*args: str) -> str:
|
|
162
|
+
rc, out, _ = run_git(list(args), cwd=root, timeout=20)
|
|
163
|
+
if rc:
|
|
164
|
+
raise ContinuationError("git source snapshot failed")
|
|
165
|
+
return out.strip()
|
|
166
|
+
|
|
167
|
+
dirty = [
|
|
168
|
+
line
|
|
169
|
+
for line in run("status", "--porcelain=v1").splitlines()
|
|
170
|
+
if line and ".trellis/.runtime/continuation-records/" not in line[3:]
|
|
171
|
+
]
|
|
172
|
+
branch = run("branch", "--show-current")
|
|
173
|
+
head = run("rev-parse", "HEAD")
|
|
174
|
+
return {
|
|
175
|
+
"branch": branch or None,
|
|
176
|
+
"head": _text(head, "git.head", 128),
|
|
177
|
+
"worktree_state": "dirty" if dirty else "clean",
|
|
178
|
+
"dirty_paths_digest": _digest(dirty),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _evidence(root: Path, values: Any) -> list[dict[str, Any]]:
|
|
183
|
+
if not isinstance(values, list) or len(values) > MAX_EVIDENCE:
|
|
184
|
+
raise ContinuationError("evidence must be a bounded list")
|
|
185
|
+
result = []
|
|
186
|
+
seen: set[str] = set()
|
|
187
|
+
for item in values:
|
|
188
|
+
path = _project_file(root, item, "evidence path")
|
|
189
|
+
relative = path.relative_to(root).as_posix()
|
|
190
|
+
if relative in seen:
|
|
191
|
+
raise ContinuationError("evidence paths must be unique")
|
|
192
|
+
seen.add(relative)
|
|
193
|
+
file_digest, size = _file_digest(path)
|
|
194
|
+
result.append({"path": relative, "bytes": size, "sha256": file_digest})
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _source(root: Path, task: dict[str, Any], evidence: list[dict[str, Any]]) -> dict[str, Any]:
|
|
199
|
+
git = _git_source(root)
|
|
200
|
+
source = {
|
|
201
|
+
"task": {key: task[key] for key in ("id", "path", "status", "material_digest")},
|
|
202
|
+
"git": git,
|
|
203
|
+
"evidence": evidence,
|
|
204
|
+
}
|
|
205
|
+
source["source_digest"] = _digest(source)
|
|
206
|
+
return source
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _request(root: Path, path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
210
|
+
_regular(path, "continuity request")
|
|
211
|
+
if path.stat().st_size > MAX_REQUEST_BYTES:
|
|
212
|
+
raise ContinuationError("continuity request is too large")
|
|
213
|
+
try:
|
|
214
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
215
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
216
|
+
raise ContinuationError("continuity request is invalid") from exc
|
|
217
|
+
if not isinstance(value, dict) or set(value) != {"objective", "decisions", "completed", "open_items", "blockers", "next_safe_action", "non_transferable_operations", "evidence"}:
|
|
218
|
+
raise ContinuationError("continuity request fields are invalid")
|
|
219
|
+
content = {key: _text(value[key], f"content.{key}") if key in {"objective", "next_safe_action"} else _list(value[key], f"content.{key}") for key in value if key != "evidence"}
|
|
220
|
+
return content, _evidence(root, value["evidence"])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _request_path(root: Path, path: Path) -> Path:
|
|
224
|
+
try:
|
|
225
|
+
relative = path.relative_to(root)
|
|
226
|
+
except ValueError as exc:
|
|
227
|
+
raise ContinuationError("continuity request must be project-relative") from exc
|
|
228
|
+
return _project_file(root, relative.as_posix(), "continuity request")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _expected_digest(value: str) -> str:
|
|
232
|
+
if value == "absent" or SHA256.fullmatch(value):
|
|
233
|
+
return value
|
|
234
|
+
raise ContinuationError("expected record digest is invalid")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _ensure_record_parent(path: Path) -> None:
|
|
238
|
+
if path.parent.is_symlink() or path.parent.parent.is_symlink():
|
|
239
|
+
raise ContinuationError("continuation record directory is unsafe")
|
|
240
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
if path.parent.is_symlink() or path.parent.parent.is_symlink():
|
|
242
|
+
raise ContinuationError("continuation record directory is unsafe")
|
|
243
|
+
os.chmod(path.parent.parent, 0o700)
|
|
244
|
+
os.chmod(path.parent, 0o700)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@contextmanager
|
|
248
|
+
def _record_lock(path: Path):
|
|
249
|
+
_ensure_record_parent(path)
|
|
250
|
+
lock = path.parent / ".lock"
|
|
251
|
+
if lock.is_symlink():
|
|
252
|
+
raise ContinuationError("continuation record lock is unsafe")
|
|
253
|
+
# ponytail: one advisory lock per task record; a lock service is unnecessary unless distributed writers are introduced.
|
|
254
|
+
with lock.open("a+", encoding="utf-8") as handle:
|
|
255
|
+
try:
|
|
256
|
+
import fcntl
|
|
257
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
258
|
+
except (ImportError, OSError) as exc:
|
|
259
|
+
raise ContinuationError("continuation record lock unavailable") from exc
|
|
260
|
+
try:
|
|
261
|
+
yield
|
|
262
|
+
finally:
|
|
263
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _record_digest(record: dict[str, Any]) -> str:
|
|
267
|
+
return _digest({key: value for key, value in record.items() if key != "integrity"})
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _decode(path: Path) -> dict[str, Any]:
|
|
271
|
+
_regular(path, "continuation record")
|
|
272
|
+
if path.stat().st_size > MAX_RECORD_BYTES:
|
|
273
|
+
raise ContinuationError("continuation record is too large")
|
|
274
|
+
data, reason = read_json_checked(path)
|
|
275
|
+
if data is None:
|
|
276
|
+
raise ContinuationError(f"continuation record is {reason}")
|
|
277
|
+
expected = {"schema_version", "kind", "task", "source", "content", "revision", "updated_at", "integrity"}
|
|
278
|
+
if set(data) != expected or data["schema_version"] != SCHEMA_VERSION or data["kind"] != KIND:
|
|
279
|
+
raise ContinuationError("continuation record schema is unsupported")
|
|
280
|
+
task = data["task"]
|
|
281
|
+
if not isinstance(task, dict) or set(task) != {"id", "path", "status", "material_digest"}:
|
|
282
|
+
raise ContinuationError("continuation record task is invalid")
|
|
283
|
+
_safe_task_id(task["id"])
|
|
284
|
+
task_path = _text(task["path"], "task.path", 1024)
|
|
285
|
+
task_path_value = Path(task_path)
|
|
286
|
+
if task_path_value.is_absolute() or ".." in task_path_value.parts or not task_path.startswith(".trellis/tasks/"):
|
|
287
|
+
raise ContinuationError("task.path is unsafe")
|
|
288
|
+
_text(task["status"], "task.status", 64)
|
|
289
|
+
if not SHA256.fullmatch(task["material_digest"]):
|
|
290
|
+
raise ContinuationError("task.material_digest is invalid")
|
|
291
|
+
source = data["source"]
|
|
292
|
+
if not isinstance(source, dict) or set(source) != {"task", "git", "evidence", "source_digest"}:
|
|
293
|
+
raise ContinuationError("continuation record source is invalid")
|
|
294
|
+
if source["task"] != task:
|
|
295
|
+
raise ContinuationError("continuation record task source is invalid")
|
|
296
|
+
git = source["git"]
|
|
297
|
+
if not isinstance(git, dict) or set(git) != {"branch", "head", "worktree_state", "dirty_paths_digest"}:
|
|
298
|
+
raise ContinuationError("continuation record git source is invalid")
|
|
299
|
+
if git["branch"] is not None:
|
|
300
|
+
_text(git["branch"], "git.branch", 512)
|
|
301
|
+
_text(git["head"], "git.head", 128)
|
|
302
|
+
if git["worktree_state"] not in {"clean", "dirty"} or not SHA256.fullmatch(git["dirty_paths_digest"]):
|
|
303
|
+
raise ContinuationError("continuation record git source is invalid")
|
|
304
|
+
if not isinstance(source["evidence"], list) or len(source["evidence"]) > MAX_EVIDENCE:
|
|
305
|
+
raise ContinuationError("continuation record evidence is invalid")
|
|
306
|
+
for index, item in enumerate(source["evidence"]):
|
|
307
|
+
if not isinstance(item, dict) or set(item) != {"path", "bytes", "sha256"}:
|
|
308
|
+
raise ContinuationError("continuation record evidence item is invalid")
|
|
309
|
+
_text(item["path"], f"evidence[{index}].path", 1024)
|
|
310
|
+
if not isinstance(item["bytes"], int) or item["bytes"] < 0 or not SHA256.fullmatch(item["sha256"]):
|
|
311
|
+
raise ContinuationError("continuation record evidence item is invalid")
|
|
312
|
+
if not SHA256.fullmatch(source["source_digest"]):
|
|
313
|
+
raise ContinuationError("source digest is invalid")
|
|
314
|
+
if source["source_digest"] != _digest({key: value for key, value in source.items() if key != "source_digest"}):
|
|
315
|
+
raise ContinuationError("source digest does not match")
|
|
316
|
+
content = data["content"]
|
|
317
|
+
if not isinstance(content, dict) or set(content) != {"objective", "decisions", "completed", "open_items", "blockers", "next_safe_action", "non_transferable_operations"}:
|
|
318
|
+
raise ContinuationError("continuation record content is invalid")
|
|
319
|
+
_text(content["objective"], "content.objective")
|
|
320
|
+
_text(content["next_safe_action"], "content.next_safe_action")
|
|
321
|
+
for key in ("decisions", "completed", "open_items", "blockers", "non_transferable_operations"):
|
|
322
|
+
_list(content[key], f"content.{key}")
|
|
323
|
+
if not isinstance(data["revision"], int) or data["revision"] < 1:
|
|
324
|
+
raise ContinuationError("revision is invalid")
|
|
325
|
+
_text(data["updated_at"], "updated_at", 128)
|
|
326
|
+
integrity = data["integrity"]
|
|
327
|
+
if not isinstance(integrity, dict) or set(integrity) != {"record_digest"} or not SHA256.fullmatch(integrity["record_digest"]):
|
|
328
|
+
raise ContinuationError("record digest is invalid")
|
|
329
|
+
if integrity["record_digest"] != _record_digest(data):
|
|
330
|
+
raise ContinuationError("record digest does not match")
|
|
331
|
+
return data
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def status(root: Path | None = None) -> dict[str, Any]:
|
|
335
|
+
root = (root or get_repo_root()).resolve()
|
|
336
|
+
try:
|
|
337
|
+
task = _task_source(root)
|
|
338
|
+
except NoDirectCurrentTask:
|
|
339
|
+
return {"status": STATUS_ABSENT, "reason": "no_direct_current_task"}
|
|
340
|
+
except ContinuationError as exc:
|
|
341
|
+
return {"status": STATUS_WITHHELD, "reason": str(exc)}
|
|
342
|
+
try:
|
|
343
|
+
path = _record_path(root, task["id"])
|
|
344
|
+
except ContinuationError as exc:
|
|
345
|
+
return {"status": STATUS_WITHHELD, "reason": str(exc)}
|
|
346
|
+
if not path.exists():
|
|
347
|
+
return {"status": STATUS_ABSENT, "task": {key: task[key] for key in ("id", "path", "status")}}
|
|
348
|
+
try:
|
|
349
|
+
record = _decode(path)
|
|
350
|
+
current = _source(root, task, _evidence(root, [item["path"] for item in record["source"]["evidence"]]))
|
|
351
|
+
state = STATUS_READY if record["source"] == current else STATUS_STALE
|
|
352
|
+
result = {"status": state, "task": record["task"], "revision": record["revision"], "record_digest": record["integrity"]["record_digest"]}
|
|
353
|
+
if state == STATUS_READY:
|
|
354
|
+
result["content"] = record["content"]
|
|
355
|
+
else:
|
|
356
|
+
result["reason"] = "source_drift"
|
|
357
|
+
return result
|
|
358
|
+
except (ContinuationError, OSError) as exc:
|
|
359
|
+
return {"status": STATUS_WITHHELD, "task": {key: task[key] for key in ("id", "path", "status")}, "reason": str(exc)}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def seal(root: Path, request_path: Path, expected: str) -> dict[str, Any]:
|
|
363
|
+
expected = _expected_digest(expected)
|
|
364
|
+
request_path = _request_path(root, request_path)
|
|
365
|
+
task = _task_source(root)
|
|
366
|
+
path = _record_path(root, task["id"])
|
|
367
|
+
with _record_lock(path):
|
|
368
|
+
task = _task_source(root)
|
|
369
|
+
current_digest: str | None = None
|
|
370
|
+
current: dict[str, Any] | None = None
|
|
371
|
+
if path.exists():
|
|
372
|
+
current = _decode(path)
|
|
373
|
+
current_digest = current["integrity"]["record_digest"]
|
|
374
|
+
current_source = _source(root, task, _evidence(root, [item["path"] for item in current["source"]["evidence"]]))
|
|
375
|
+
if current["source"] != current_source:
|
|
376
|
+
raise ContinuationError("existing continuation record source drifted")
|
|
377
|
+
if expected != "absent" and expected != current_digest:
|
|
378
|
+
raise ContinuationError("expected record digest does not match")
|
|
379
|
+
if expected == "absent" and current_digest is not None:
|
|
380
|
+
raise ContinuationError("record already exists")
|
|
381
|
+
content, evidence = _request(root, request_path)
|
|
382
|
+
source = _source(root, task, evidence)
|
|
383
|
+
current_task = _task_source(root)
|
|
384
|
+
if task != current_task or source != _source(root, current_task, _evidence(root, [item["path"] for item in evidence])):
|
|
385
|
+
raise ContinuationError("continuation source changed while sealing")
|
|
386
|
+
record: dict[str, Any] = {
|
|
387
|
+
"schema_version": SCHEMA_VERSION,
|
|
388
|
+
"kind": KIND,
|
|
389
|
+
"task": {key: task[key] for key in ("id", "path", "status", "material_digest")},
|
|
390
|
+
"source": source,
|
|
391
|
+
"content": content,
|
|
392
|
+
"revision": 1 if current is None else current["revision"] + 1,
|
|
393
|
+
"updated_at": datetime.now(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z"),
|
|
394
|
+
}
|
|
395
|
+
record["integrity"] = {"record_digest": _record_digest(record)}
|
|
396
|
+
encoded = json.dumps(record, ensure_ascii=True, indent=2, sort_keys=True) + "\n"
|
|
397
|
+
if len(encoded.encode("utf-8")) > MAX_RECORD_BYTES:
|
|
398
|
+
raise ContinuationError("continuation record is too large")
|
|
399
|
+
temporary: Path | None = None
|
|
400
|
+
try:
|
|
401
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
402
|
+
temporary = Path(handle.name)
|
|
403
|
+
handle.write(encoded)
|
|
404
|
+
handle.flush()
|
|
405
|
+
os.fsync(handle.fileno())
|
|
406
|
+
os.chmod(temporary, 0o600)
|
|
407
|
+
os.replace(temporary, path)
|
|
408
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
409
|
+
try:
|
|
410
|
+
os.fsync(directory_fd)
|
|
411
|
+
finally:
|
|
412
|
+
os.close(directory_fd)
|
|
413
|
+
except OSError as exc:
|
|
414
|
+
if temporary is not None:
|
|
415
|
+
temporary.unlink(missing_ok=True)
|
|
416
|
+
raise ContinuationError("continuation record atomic write failed") from exc
|
|
417
|
+
return {"status": STATUS_READY, "revision": record["revision"], "record_digest": record["integrity"]["record_digest"]}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def clear(root: Path, expected: str) -> dict[str, Any]:
|
|
421
|
+
expected = _expected_digest(expected)
|
|
422
|
+
task = _task_source(root)
|
|
423
|
+
path = _record_path(root, task["id"])
|
|
424
|
+
with _record_lock(path):
|
|
425
|
+
if not path.exists():
|
|
426
|
+
if expected == "absent":
|
|
427
|
+
return {"status": STATUS_ABSENT}
|
|
428
|
+
raise ContinuationError("record is absent")
|
|
429
|
+
record = _decode(path)
|
|
430
|
+
actual = record["integrity"]["record_digest"]
|
|
431
|
+
if actual != expected:
|
|
432
|
+
raise ContinuationError("expected record digest does not match")
|
|
433
|
+
path.unlink()
|
|
434
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
435
|
+
try:
|
|
436
|
+
os.fsync(directory_fd)
|
|
437
|
+
finally:
|
|
438
|
+
os.close(directory_fd)
|
|
439
|
+
return {"status": STATUS_ABSENT, "cleared": actual}
|
|
@@ -22,6 +22,7 @@ import sys
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
24
|
from .active_task import resolve_context_key
|
|
25
|
+
from .continuation_record import status as continuity_status
|
|
25
26
|
from .config import get_git_packages
|
|
26
27
|
from .git import run_git
|
|
27
28
|
from .packages_context import get_packages_section
|
|
@@ -558,6 +559,8 @@ def get_context_json(repo_root: Path | None = None) -> dict:
|
|
|
558
559
|
if pkg_git_info:
|
|
559
560
|
result["packageGit"] = pkg_git_info
|
|
560
561
|
|
|
562
|
+
result["continuity"] = continuity_status(repo_root)
|
|
563
|
+
|
|
561
564
|
return result
|
|
562
565
|
|
|
563
566
|
|
|
@@ -644,6 +647,8 @@ def get_context_text(repo_root: Path | None = None) -> str:
|
|
|
644
647
|
lines.append("[!] This task has prd.md - read it for task details")
|
|
645
648
|
else:
|
|
646
649
|
lines.append("(none)")
|
|
650
|
+
continuity = continuity_status(repo_root)
|
|
651
|
+
lines.append(f"Continuation Record: {continuity['status']}")
|
|
647
652
|
lines.append("")
|
|
648
653
|
|
|
649
654
|
# Active tasks
|
|
@@ -797,6 +802,8 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
|
797
802
|
if pkg_git_info:
|
|
798
803
|
result["packageGit"] = pkg_git_info
|
|
799
804
|
|
|
805
|
+
result["continuity"] = continuity_status(repo_root)
|
|
806
|
+
|
|
800
807
|
return result
|
|
801
808
|
|
|
802
809
|
|
|
@@ -74,6 +74,13 @@ from common.task_context import (
|
|
|
74
74
|
cmd_list_context,
|
|
75
75
|
curated_entry_count,
|
|
76
76
|
)
|
|
77
|
+
from common.continuation_record import (
|
|
78
|
+
STATUS_WITHHELD,
|
|
79
|
+
ContinuationError,
|
|
80
|
+
clear as clear_continuity,
|
|
81
|
+
seal as seal_continuity,
|
|
82
|
+
status as continuity_status,
|
|
83
|
+
)
|
|
77
84
|
|
|
78
85
|
|
|
79
86
|
# =============================================================================
|
|
@@ -349,6 +356,32 @@ def cmd_current(args: argparse.Namespace) -> int:
|
|
|
349
356
|
return 1
|
|
350
357
|
|
|
351
358
|
|
|
359
|
+
def cmd_continuity(args: argparse.Namespace) -> int:
|
|
360
|
+
"""Read or explicitly mutate the current task's Continuation Record."""
|
|
361
|
+
repo_root = get_repo_root()
|
|
362
|
+
try:
|
|
363
|
+
if args.continuity_command == "status":
|
|
364
|
+
result = continuity_status(repo_root)
|
|
365
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
366
|
+
return 0
|
|
367
|
+
if not getattr(args, "explicit_user_request", False):
|
|
368
|
+
print(colored("Error: continuity writes require --explicit-user-request", Colors.RED), file=sys.stderr)
|
|
369
|
+
return 2
|
|
370
|
+
if args.continuity_command == "seal":
|
|
371
|
+
request = Path(args.request)
|
|
372
|
+
if request.is_absolute() or ".." in request.parts:
|
|
373
|
+
print(colored("Error: continuity request must be project-relative", Colors.RED), file=sys.stderr)
|
|
374
|
+
return 2
|
|
375
|
+
result = seal_continuity(repo_root, repo_root / request, args.expected)
|
|
376
|
+
else:
|
|
377
|
+
result = clear_continuity(repo_root, args.expected)
|
|
378
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
379
|
+
return 0
|
|
380
|
+
except (ContinuationError, OSError) as exc:
|
|
381
|
+
print(json.dumps({"status": STATUS_WITHHELD, "reason": str(exc)}, ensure_ascii=False, sort_keys=True))
|
|
382
|
+
return 2
|
|
383
|
+
|
|
384
|
+
|
|
352
385
|
# =============================================================================
|
|
353
386
|
# Command: list
|
|
354
387
|
# =============================================================================
|
|
@@ -697,6 +730,19 @@ def main() -> int:
|
|
|
697
730
|
p_current.add_argument("--json", action="store_true",
|
|
698
731
|
help="Output machine-readable JSON")
|
|
699
732
|
|
|
733
|
+
# continuity
|
|
734
|
+
p_continuity = subparsers.add_parser("continuity", help="Read or explicitly update the current task Continuation Record")
|
|
735
|
+
continuity_sub = p_continuity.add_subparsers(dest="continuity_command", required=True)
|
|
736
|
+
continuity_status_parser = continuity_sub.add_parser("status", help="Show read-only Continuation Record status")
|
|
737
|
+
continuity_status_parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
738
|
+
continuity_seal = continuity_sub.add_parser("seal", help="Write a Continuation Record from an explicit request")
|
|
739
|
+
continuity_seal.add_argument("--request", required=True, type=Path, help="Project-relative bounded request JSON")
|
|
740
|
+
continuity_seal.add_argument("--expected", required=True, help="absent or the current record digest")
|
|
741
|
+
continuity_seal.add_argument("--explicit-user-request", action="store_true", help="Required write confirmation")
|
|
742
|
+
continuity_clear = continuity_sub.add_parser("clear", help="Clear the current Continuation Record")
|
|
743
|
+
continuity_clear.add_argument("--expected", required=True, help="The current record digest or absent")
|
|
744
|
+
continuity_clear.add_argument("--explicit-user-request", action="store_true", help="Required write confirmation")
|
|
745
|
+
|
|
700
746
|
# finish
|
|
701
747
|
subparsers.add_parser("finish", help="Clear active task")
|
|
702
748
|
|
|
@@ -777,6 +823,7 @@ def main() -> int:
|
|
|
777
823
|
"list-context": cmd_list_context,
|
|
778
824
|
"start": cmd_start,
|
|
779
825
|
"current": cmd_current,
|
|
826
|
+
"continuity": cmd_continuity,
|
|
780
827
|
"finish": cmd_finish,
|
|
781
828
|
"set-branch": cmd_set_branch,
|
|
782
829
|
"set-base-branch": cmd_set_base_branch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pennixrv/trellis",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.26",
|
|
4
4
|
"description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"inquirer": "^9.3.7",
|
|
35
35
|
"undici": "^6.21.0",
|
|
36
36
|
"zod": "^4.4.2",
|
|
37
|
-
"@pennixrv/trellis-core": "0.6.
|
|
37
|
+
"@pennixrv/trellis-core": "0.6.26"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/js": "^9.18.0",
|